diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx index a11614e2bf..3334600117 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx @@ -19,6 +19,7 @@ import DatalistToolbar from '../../../components/DataListToolbar'; import AlertModal from '../../../components/AlertModal/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail/ErrorDetail'; import InventorySourceListItem from './InventorySourceListItem'; +import useWsInventorySources from './useWsInventorySources'; const QS_CONFIG = getQSConfig('inventory', { not__source: '', @@ -34,7 +35,7 @@ function InventorySourceList({ i18n }) { const { isLoading, error: fetchError, - result: { sources, sourceCount, sourceChoices, sourceChoicesOptions }, + result: { result, sourceCount, sourceChoices, sourceChoicesOptions }, request: fetchSources, } = useRequest( useCallback(async () => { @@ -44,18 +45,21 @@ function InventorySourceList({ i18n }) { InventorySourcesAPI.readOptions(), ]); return { - sources: results[0].data.results, + result: results[0].data.results, sourceCount: results[0].data.count, sourceChoices: results[1].data.actions.GET.source.choices, sourceChoicesOptions: results[1].data.actions, }; }, [id, search]), { - sources: [], + result: [], sourceCount: 0, sourceChoices: [], } ); + + const sources = useWsInventorySources(result); + const canSyncSources = sources.length > 0 && sources.every(source => source.summary_fields.user_capabilities.start); diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx index 9ed97cfb1f..97b2d4c8e3 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx @@ -55,8 +55,11 @@ const sources = { describe('', () => { let wrapper; let history; + let debug; beforeEach(async () => { + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; InventoriesAPI.readSources.mockResolvedValue(sources); InventorySourcesAPI.readOptions.mockResolvedValue({ data: { @@ -98,6 +101,7 @@ describe('', () => { afterEach(() => { wrapper.unmount(); jest.clearAllMocks(); + global.console.debug = debug; }); test('should mount properly', async () => { diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.js b/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.js new file mode 100644 index 0000000000..49a9b4f387 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.js @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react'; +import useWebsocket from '../../../util/useWebsocket'; + +export default function useWsJobs(initialSources) { + const [sources, setSources] = useState(initialSources); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); + + useEffect(() => { + setSources(initialSources); + }, [initialSources]); + + useEffect( + function parseWsMessage() { + if (!lastMessage?.unified_job_id || !lastMessage?.inventory_source_id) { + return; + } + + const sourceId = lastMessage.inventory_source_id; + const index = sources.findIndex(s => s.id === sourceId); + if (index > -1) { + setSources(updateSource(sources, index, lastMessage)); + } + }, + [lastMessage] // eslint-disable-line react-hooks/exhaustive-deps + ); + + return sources; +} + +function updateSource(sources, index, message) { + const source = { + ...sources[index], + status: message.status, + last_updated: message.finished, + summary_fields: { + ...sources[index].summary_fields, + last_job: { + id: message.unified_job_id, + status: message.status, + finished: message.finished, + }, + }, + }; + return [...sources.slice(0, index), source, ...sources.slice(index + 1)]; +} diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.test.jsx new file mode 100644 index 0000000000..b0e5668624 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.test.jsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import WS from 'jest-websocket-mock'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import useWsInventorySources from './useWsInventorySources'; + +/* + Jest mock timers don’t play well with jest-websocket-mock, + so we'll stub out throttling to resolve immediately +*/ +jest.mock('../../../util/useThrottle', () => ({ + __esModule: true, + default: jest.fn(val => val), +})); + +function TestInner() { + return
; +} +function Test({ sources }) { + const syncedSources = useWsInventorySources(sources); + return ; +} + +describe('useWsInventorySources hook', () => { + let debug; + let wrapper; + beforeEach(() => { + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; + }); + + afterEach(() => { + global.console.debug = debug; + }); + + test('should return sources list', () => { + const sources = [{ id: 1 }]; + wrapper = mountWithContexts(); + + expect(wrapper.find('TestInner').prop('sources')).toEqual(sources); + WS.clean(); + }); + + test('should establish websocket connection', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const sources = [{ id: 1 }]; + await act(async () => { + wrapper = await mountWithContexts(); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + WS.clean(); + }); + + test('should update last job status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const sources = [ + { + id: 3, + status: 'running', + summary_fields: { + last_job: { + id: 5, + status: 'running', + }, + }, + }, + ]; + await act(async () => { + wrapper = await mountWithContexts(); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + act(() => { + mockServer.send( + JSON.stringify({ + unified_job_id: 5, + inventory_source_id: 3, + type: 'job', + status: 'successful', + finished: 'the_time', + }) + ); + }); + wrapper.update(); + + const source = wrapper.find('TestInner').prop('sources')[0]; + expect(source).toEqual({ + id: 3, + status: 'successful', + last_updated: 'the_time', + summary_fields: { + last_job: { + id: 5, + status: 'successful', + finished: 'the_time', + }, + }, + }); + WS.clean(); + }); +});