Skip to content

Commit

Permalink
feat(gui): added possibility to trigger deployment & inventory data u…
Browse files Browse the repository at this point in the history
…pdates when troubleshooting

Ticket: MEN-7657
Changelog: Title
Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
  • Loading branch information
mzedel committed Dec 6, 2024
1 parent 89b0c73 commit 11a9b7a
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@
// limitations under the License.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Dropzone from 'react-dropzone';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';

import { Button } from '@mui/material';
import { makeStyles } from 'tss-react/mui';

import Loader from '@northern.tech/common-ui/loader';
import { MaybeTime } from '@northern.tech/common-ui/time';
import { BEGINNING_OF_TIME, TIMEOUTS } from '@northern.tech/store/constants';
import { getCurrentSession, getFeatures, getIsPreview, getTenantCapabilities, getUserCapabilities } from '@northern.tech/store/selectors';
import { triggerDeviceUpdate } from '@northern.tech/store/thunks';
import { useSession } from '@northern.tech/utils/sockethook';
import dayjs from 'dayjs';
import durationDayJs from 'dayjs/plugin/duration';
Expand Down Expand Up @@ -84,10 +86,25 @@ const SessionInfo = ({ socketInitialized, startTime }) => {
);
};

const DeviceUpdateTitle = ({ loading, title }) => {
if (!loading) {
return <div>{title}</div>;
}
return (
<div className="flexbox center-aligned">
<div className="margin-right-x-small">{title}</div>
<Loader show small table style={{ top: -20 }} />
</div>
);
};

const TroubleshootContent = ({ device, onDownload, setSocketClosed, setUploadPath, setFile, setSnackbar, setSocketInitialized, socketInitialized }) => {
const [terminalInput, setTerminalInput] = useState('');
const [startTime, setStartTime] = useState();
const [snackbarAlreadySet, setSnackbarAlreadySet] = useState(false);
const [isAwaitingCheckInUpdate, setIsAwaitingCheckInUpdate] = useState(false);
const [isAwaitingInventoryUpdate, setIsAwaitingInventoryUpdate] = useState(false);
const inventoryTimer = useRef();
const snackTimer = useRef();
const { classes } = useStyles();
const termRef = useRef({ terminal: React.createRef(), terminalRef: React.createRef() });
Expand All @@ -97,6 +114,8 @@ const TroubleshootContent = ({ device, onDownload, setSocketClosed, setUploadPat
const { canAuditlog } = useSelector(getUserCapabilities);
const canPreview = useSelector(getIsPreview);
const { token } = useSelector(getCurrentSession);
const dispatch = useDispatch();

const onMessageReceived = useCallback(message => {
if (!termRef.current.terminal.current) {
return;
Expand Down Expand Up @@ -183,6 +202,21 @@ const TroubleshootContent = ({ device, onDownload, setSocketClosed, setUploadPat
return close;
}, [close, sessionState]);

useEffect(() => {
setIsAwaitingCheckInUpdate(false);
}, [device.check_in_time]);

useEffect(() => {
setIsAwaitingInventoryUpdate(false);
inventoryTimer.current = setTimeout(() => setIsAwaitingInventoryUpdate(false), TIMEOUTS.refreshLong);
}, [device.updated_ts]);

useEffect(() => {
return () => {
clearTimeout(inventoryTimer.current);
};
}, []);

const onConnectionToggle = () => {
if (sessionState === WebSocket.CLOSED) {
setStartTime();
Expand All @@ -201,14 +235,35 @@ const TroubleshootContent = ({ device, onDownload, setSocketClosed, setUploadPat
setTerminalInput(code);
};

const onTriggerUpdateClick = useCallback(() => {
setIsAwaitingCheckInUpdate(true);
dispatch(triggerDeviceUpdate({ id: device.id, type: 'deploymentUpdate' }));
}, [dispatch, device.id]);

const onRequestInventoryUpdateClick = useCallback(() => {
setIsAwaitingInventoryUpdate(true);
dispatch(triggerDeviceUpdate({ id: device.id, type: 'inventoryUpdate' }));
}, [dispatch, device.id]);

const onDrop = acceptedFiles => {
if (acceptedFiles.length === 1) {
setFile(acceptedFiles[0]);
setUploadPath(`/tmp/${acceptedFiles[0].name}`);
}
};

const commandHandlers = isHosted && isEnterprise ? [{ key: 'thing', onClick: onMakeGatewayClick, title: 'Promote to Mender gateway' }] : [];
const commonCommandHandlers = [
{ key: 'updateCheck', onClick: onTriggerUpdateClick, title: <DeviceUpdateTitle title="Trigger update check" loading={isAwaitingCheckInUpdate} /> },
{
key: 'inventoryUpdate',
onClick: onRequestInventoryUpdateClick,
title: <DeviceUpdateTitle title="Request inventory update" loading={isAwaitingInventoryUpdate} />
}
];
const commandHandlers =
isHosted && isEnterprise
? [{ key: 'gatewayPromotion', onClick: onMakeGatewayClick, title: 'Promote to Mender gateway' }, ...commonCommandHandlers]
: commonCommandHandlers;

const visibilityToggle = !socketInitialized ? { maxHeight: 0, overflow: 'hidden' } : {};
return (
Expand Down
40 changes: 37 additions & 3 deletions frontend/src/js/store/devicesSlice/thunks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { inventoryDevice } from '../../../../tests/__mocks__/deviceHandlers';
import { defaultState } from '../../../../tests/mockData';
import { act, mockAbortController } from '../../../../tests/setupTests';
import { actions as appActions } from '../appSlice';
import { EXTERNAL_PROVIDER, UNGROUPED_GROUP } from '../constants';
import { EXTERNAL_PROVIDER, TIMEOUTS, UNGROUPED_GROUP } from '../constants';
import { actions as deploymentActions } from '../deploymentsSlice';
import { DEVICE_STATES } from './constants';
import {
Expand Down Expand Up @@ -73,6 +73,7 @@ import {
setDeviceListState,
setDeviceTags,
setDeviceTwin,
triggerDeviceUpdate,
updateDeviceAuth,
updateDevicesAuth,
updateDynamicGroup
Expand Down Expand Up @@ -991,8 +992,8 @@ describe('device retrieval ', () => {
{ type: getDeviceById.fulfilled.type },
{ type: getDeviceAuth.fulfilled.type },
{ type: actions.receivedDevice.type, payload: { connect_status: 'connected', connect_updated_ts: updated_ts, id } },
{ type: actions.receivedDevice.type, payload: expectedDevice },
{ type: getDeviceConnect.fulfilled.type },
{ type: actions.receivedDevice.type, payload: expectedDevice },
{ type: getDeviceTwin.fulfilled.type },
{ type: getDeviceInfo.fulfilled.type }
];
Expand Down Expand Up @@ -1185,7 +1186,40 @@ describe('troubleshooting related actions', () => {

expect(result).toMatchObject({ start: new Date(endDate), end: new Date(endDate) });
});

it('should allow triggering device inventory updates', async () => {
const store = mockStore({ ...defaultState });
const { attributes, id } = defaultState.devices.byId.a1;
const expectedActions = [
{ type: triggerDeviceUpdate.pending.type },
{ type: getDeviceById.pending.type },
{ type: actions.receivedDevice.type, payload: { attributes, id } },
{ type: getDeviceById.fulfilled.type },
{ type: triggerDeviceUpdate.fulfilled.type }
];
// no await here to allow moving beyond the delayed device info update in the next line
store.dispatch(triggerDeviceUpdate({ id: defaultState.devices.byId.a1.id, type: 'inventoryUpdate' }));
await jest.advanceTimersByTimeAsync(TIMEOUTS.fiveSeconds);
const storeActions = store.getActions();
expect(storeActions.length).toEqual(expectedActions.length);
expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action));
});
it('should allow triggering device deployment update checks', async () => {
const store = mockStore({ ...defaultState });
const { attributes, id } = defaultState.devices.byId.a1;
const expectedActions = [
{ type: triggerDeviceUpdate.pending.type },
{ type: getDeviceById.pending.type },
{ type: actions.receivedDevice.type, payload: { attributes, id } },
{ type: getDeviceById.fulfilled.type },
{ type: triggerDeviceUpdate.fulfilled.type }
];
// no await here to allow moving beyond the delayed device info update in the next line
store.dispatch(triggerDeviceUpdate({ id: defaultState.devices.byId.a1.id, type: 'deploymentUpdate' }));
await jest.advanceTimersByTimeAsync(TIMEOUTS.fiveSeconds);
const storeActions = store.getActions();
expect(storeActions.length).toEqual(expectedActions.length);
expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action));
});
it('should allow device file transfers', async () => {
const store = mockStore({ ...defaultState });
const link = await store.dispatch(getDeviceFileDownloadLink({ deviceId: 'aDeviceId', path: '/tmp/file' })).unwrap();
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/js/store/devicesSlice/thunks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,13 @@ export const getDeviceConnect = createAsyncThunk(`${sliceName}/getDeviceConnect`
)
);

const updateTypeMap = { deploymentUpdate: 'check-update', inventoryUpdate: 'send-inventory' };
export const triggerDeviceUpdate = createAsyncThunk(`${sliceName}/triggerDeviceUpdate`, ({ id, type }, { dispatch }) =>
GeneralApi.post(`${deviceConnect}/devices/${id}/${updateTypeMap[type] ?? updateTypeMap.deploymentUpdate}`).then(
() => new Promise(resolve => setTimeout(() => resolve(dispatch(getDeviceById(id))), TIMEOUTS.threeSeconds))
)
);

export const getSessionDetails = createAsyncThunk(`${sliceName}/getSessionDetails`, ({ sessionId, deviceId, userId, startDate, endDate }) => {
const createdAfter = startDate ? `&created_after=${Math.round(Date.parse(startDate) / 1000)}` : '';
const createdBefore = endDate ? `&created_before=${Math.round(Date.parse(endDate) / 1000)}` : '';
Expand Down
6 changes: 6 additions & 0 deletions frontend/tests/__mocks__/deviceHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,12 @@ export const deviceHandlers = [
}
return new HttpResponse(null, { status: 512 });
}),
http.post(`${deviceConnect}/devices/:deviceId/:action`, ({ params: { deviceId, action } }) => {
if (['check-update', 'send-inventory'].includes(action) && defaultState.devices.byId[deviceId]) {
return new HttpResponse(null, { status: 202 });
}
return new HttpResponse(null, { status: 518 });
}),
http.get(`${iotManagerBaseURL}/devices/:deviceId/state`, ({ params: { deviceId } }) => {
if (defaultState.devices.byId[deviceId]) {
return HttpResponse.json({ deployment_id: defaultState.deployments.byId.d1.id });
Expand Down
20 changes: 20 additions & 0 deletions frontend/tests/e2e_tests/integration/05-deviceDetails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,24 @@ test.describe('Device details', () => {
expect(pass2).not.toBeTruthy();
}
});

test('can trigger on device updates', async ({ loggedInPage: page }) => {
await page.click(`.leftNav :text('Devices')`);
await page.locator(`css=${selectors.deviceListItem} div:last-child`).last().click();
await page.getByText(/troubleshooting/i).click();
// the deviceconnect connection might not be established right away
await page.getByText(/Session status/i).waitFor({ timeout: timeouts.tenSeconds });
const connectionButton = await page.getByRole('button', { name: /connect/i });
await connectionButton.first().click();
await page.getByText('Connection with the device established').waitFor({ timeout: timeouts.tenSeconds });
const quickActionMenu = await page.getByText(/quick commands/i);
await quickActionMenu.scrollIntoViewIfNeeded();
await quickActionMenu.click();
const updateLoadingIndicator = page.locator('li .miniLoaderContainer');
await expect(updateLoadingIndicator).not.toBeVisible();
await page.getByRole('menuitem', { name: 'Trigger update check' }).click();
await expect(updateLoadingIndicator).toBeVisible();
await updateLoadingIndicator.waitFor({ state: 'hidden', timeout: timeouts.tenSeconds });
await expect(updateLoadingIndicator).not.toBeVisible();
});
});

0 comments on commit 11a9b7a

Please sign in to comment.