From 11a9b7a57a179c3d9605779b41f6d10b6dbc72fb Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Fri, 29 Nov 2024 15:32:01 +0100 Subject: [PATCH] feat(gui): added possibility to trigger deployment & inventory data updates when troubleshooting Ticket: MEN-7657 Changelog: Title Signed-off-by: Manuel Zedel --- .../devices/troubleshoot/terminal-wrapper.js | 59 ++++++++++++++++++- .../src/js/store/devicesSlice/thunks.test.tsx | 40 ++++++++++++- frontend/src/js/store/devicesSlice/thunks.tsx | 7 +++ frontend/tests/__mocks__/deviceHandlers.js | 6 ++ .../integration/05-deviceDetails.spec.ts | 20 +++++++ 5 files changed, 127 insertions(+), 5 deletions(-) diff --git a/frontend/src/js/components/devices/troubleshoot/terminal-wrapper.js b/frontend/src/js/components/devices/troubleshoot/terminal-wrapper.js index bd4dec0a..0d0f293c 100644 --- a/frontend/src/js/components/devices/troubleshoot/terminal-wrapper.js +++ b/frontend/src/js/components/devices/troubleshoot/terminal-wrapper.js @@ -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'; @@ -84,10 +86,25 @@ const SessionInfo = ({ socketInitialized, startTime }) => { ); }; +const DeviceUpdateTitle = ({ loading, title }) => { + if (!loading) { + return
{title}
; + } + return ( +
+
{title}
+ +
+ ); +}; + 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() }); @@ -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; @@ -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(); @@ -201,6 +235,16 @@ 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]); @@ -208,7 +252,18 @@ const TroubleshootContent = ({ device, onDownload, setSocketClosed, setUploadPat } }; - const commandHandlers = isHosted && isEnterprise ? [{ key: 'thing', onClick: onMakeGatewayClick, title: 'Promote to Mender gateway' }] : []; + const commonCommandHandlers = [ + { key: 'updateCheck', onClick: onTriggerUpdateClick, title: }, + { + key: 'inventoryUpdate', + onClick: onRequestInventoryUpdateClick, + title: + } + ]; + const commandHandlers = + isHosted && isEnterprise + ? [{ key: 'gatewayPromotion', onClick: onMakeGatewayClick, title: 'Promote to Mender gateway' }, ...commonCommandHandlers] + : commonCommandHandlers; const visibilityToggle = !socketInitialized ? { maxHeight: 0, overflow: 'hidden' } : {}; return ( diff --git a/frontend/src/js/store/devicesSlice/thunks.test.tsx b/frontend/src/js/store/devicesSlice/thunks.test.tsx index 6d2cc445..eedd18c3 100644 --- a/frontend/src/js/store/devicesSlice/thunks.test.tsx +++ b/frontend/src/js/store/devicesSlice/thunks.test.tsx @@ -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 { @@ -73,6 +73,7 @@ import { setDeviceListState, setDeviceTags, setDeviceTwin, + triggerDeviceUpdate, updateDeviceAuth, updateDevicesAuth, updateDynamicGroup @@ -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 } ]; @@ -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(); diff --git a/frontend/src/js/store/devicesSlice/thunks.tsx b/frontend/src/js/store/devicesSlice/thunks.tsx index 7ef6951c..74bfd1d3 100644 --- a/frontend/src/js/store/devicesSlice/thunks.tsx +++ b/frontend/src/js/store/devicesSlice/thunks.tsx @@ -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)}` : ''; diff --git a/frontend/tests/__mocks__/deviceHandlers.js b/frontend/tests/__mocks__/deviceHandlers.js index 96e75261..a2e7c0cb 100644 --- a/frontend/tests/__mocks__/deviceHandlers.js +++ b/frontend/tests/__mocks__/deviceHandlers.js @@ -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 }); diff --git a/frontend/tests/e2e_tests/integration/05-deviceDetails.spec.ts b/frontend/tests/e2e_tests/integration/05-deviceDetails.spec.ts index 3e9ade16..ac33026a 100644 --- a/frontend/tests/e2e_tests/integration/05-deviceDetails.spec.ts +++ b/frontend/tests/e2e_tests/integration/05-deviceDetails.spec.ts @@ -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(); + }); });