diff --git a/awx/ui_next/src/components/JobList/useWsJobs.js b/awx/ui_next/src/components/JobList/useWsJobs.js index 9c939f25d9..4560930818 100644 --- a/awx/ui_next/src/components/JobList/useWsJobs.js +++ b/awx/ui_next/src/components/JobList/useWsJobs.js @@ -1,5 +1,6 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; +import useWebsocket from '../../util/useWebsocket'; import useThrottle from '../../util/useThrottle'; import { parseQueryString } from '../../util/qs'; import sortJobs from './sortJobs'; @@ -7,9 +8,13 @@ import sortJobs from './sortJobs'; export default function useWsJobs(initialJobs, fetchJobsById, qsConfig) { const location = useLocation(); const [jobs, setJobs] = useState(initialJobs); - const [lastMessage, setLastMessage] = useState(null); const [jobsToFetch, setJobsToFetch] = useState([]); const throttledJobsToFetch = useThrottle(jobsToFetch, 5000); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + schedules: ['changed'], + control: ['limit_reached_1'], + }); useEffect(() => { setJobs(initialJobs); @@ -37,8 +42,6 @@ export default function useWsJobs(initialJobs, fetchJobsById, qsConfig) { })(); }, [throttledJobsToFetch, fetchJobsById]); // eslint-disable-line react-hooks/exhaustive-deps - const ws = useRef(null); - useEffect(() => { if (!lastMessage || !lastMessage.unified_job_id) { return; @@ -61,51 +64,6 @@ export default function useWsJobs(initialJobs, fetchJobsById, qsConfig) { } }, [lastMessage]); // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { - ws.current = new WebSocket(`wss://${window.location.host}/websocket/`); - - const connect = () => { - const xrftoken = `; ${document.cookie}` - .split('; csrftoken=') - .pop() - .split(';') - .shift(); - ws.current.send( - JSON.stringify({ - xrftoken, - groups: { - jobs: ['status_changed'], - schedules: ['changed'], - control: ['limit_reached_1'], - }, - }) - ); - }; - ws.current.onopen = connect; - - ws.current.onmessage = e => { - setLastMessage(JSON.parse(e.data)); - }; - - ws.current.onclose = e => { - // eslint-disable-next-line no-console - console.debug('Socket closed. Reconnecting...', e); - setTimeout(() => { - connect(); - }, 1000); - }; - - ws.current.onerror = err => { - // eslint-disable-next-line no-console - console.debug('Socket error: ', err, 'Disconnecting...'); - ws.current.close(); - }; - - return () => { - ws.current.close(); - }; - }, []); - return jobs; } diff --git a/awx/ui_next/src/components/SyncStatusIndicator/SyncStatusIndicator.jsx b/awx/ui_next/src/components/SyncStatusIndicator/SyncStatusIndicator.jsx new file mode 100644 index 0000000000..77cb158548 --- /dev/null +++ b/awx/ui_next/src/components/SyncStatusIndicator/SyncStatusIndicator.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import 'styled-components/macro'; +import styled, { keyframes } from 'styled-components'; +import { oneOf, string } from 'prop-types'; +import { CloudIcon } from '@patternfly/react-icons'; + +const COLORS = { + success: '--pf-global--palette--green-400', + syncing: '--pf-global--palette--green-400', + error: '--pf-global--danger-color--100', + disabled: '--pf-global--disabled-color--200', +}; + +const Pulse = keyframes` + from { + opacity: 0; + } + to { + opacity: 1.0; + } +`; + +const PulseWrapper = styled.div` + animation: ${Pulse} 1.5s linear infinite alternate; +`; + +export default function SyncStatusIndicator({ status, title }) { + const color = COLORS[status] || COLORS.disabled; + + if (status === 'syncing') { + return ( + + + + ); + } + + return ; +} +SyncStatusIndicator.propTypes = { + status: oneOf(['success', 'error', 'disabled', 'syncing']).isRequired, + title: string, +}; +SyncStatusIndicator.defaultProps = { + title: null, +}; diff --git a/awx/ui_next/src/components/SyncStatusIndicator/index.js b/awx/ui_next/src/components/SyncStatusIndicator/index.js new file mode 100644 index 0000000000..8a25d03365 --- /dev/null +++ b/awx/ui_next/src/components/SyncStatusIndicator/index.js @@ -0,0 +1 @@ +export { default } from './SyncStatusIndicator'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx index dcb817fe59..65d7bed75a 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx @@ -1,7 +1,6 @@ import React, { useState, useCallback, useEffect } from 'react'; import { useLocation, useRouteMatch } from 'react-router-dom'; import { withI18n } from '@lingui/react'; - import { t } from '@lingui/macro'; import { Card, PageSection } from '@patternfly/react-core'; @@ -13,8 +12,8 @@ import ErrorDetail from '../../../components/ErrorDetail'; import PaginatedDataList, { ToolbarDeleteButton, } from '../../../components/PaginatedDataList'; - import { getQSConfig, parseQueryString } from '../../../util/qs'; +import useWsInventories from './useWsInventories'; import AddDropDownButton from '../../../components/AddDropDownButton'; import InventoryListItem from './InventoryListItem'; @@ -30,7 +29,7 @@ function InventoryList({ i18n }) { const [selected, setSelected] = useState([]); const { - result: { inventories, itemCount, actions }, + result: { results, itemCount, actions }, error: contentError, isLoading, request: fetchInventories, @@ -42,13 +41,13 @@ function InventoryList({ i18n }) { InventoriesAPI.readOptions(), ]); return { - inventories: response.data.results, + results: response.data.results, itemCount: response.data.count, actions: actionsResponse.data.actions, }; }, [location]), { - inventories: [], + results: [], itemCount: 0, actions: {}, } @@ -58,6 +57,17 @@ function InventoryList({ i18n }) { fetchInventories(); }, [fetchInventories]); + const fetchInventoriesById = useCallback( + async ids => { + const params = parseQueryString(QS_CONFIG, location.search); + params.id__in = ids.join(','); + const { data } = await InventoriesAPI.read(params); + return data.results; + }, + [location.search] // eslint-disable-line react-hooks/exhaustive-deps + ); + const inventories = useWsInventories(results, fetchInventoriesById); + const isAllSelected = selected.length === inventories.length && selected.length > 0; const { diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx index 0db85887bb..e9b256e05e 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.test.jsx @@ -119,6 +119,7 @@ const mockInventories = [ ]; describe('', () => { + let debug; beforeEach(() => { InventoriesAPI.read.mockResolvedValue({ data: { @@ -135,10 +136,13 @@ describe('', () => { }, }, }); + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; }); afterEach(() => { jest.clearAllMocks(); + global.console.debug = debug; }); test('should load and render inventories', async () => { diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx index 9d272ace69..9070656bba 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx @@ -10,16 +10,16 @@ import { DataListItemRow, Tooltip, } from '@patternfly/react-core'; - +import { PencilAltIcon } from '@patternfly/react-icons'; import { t } from '@lingui/macro'; import { Link } from 'react-router-dom'; import styled from 'styled-components'; -import { PencilAltIcon } from '@patternfly/react-icons'; import { timeOfDay } from '../../../util/dates'; import { InventoriesAPI } from '../../../api'; import { Inventory } from '../../../types'; import DataListCell from '../../../components/DataListCell'; import CopyButton from '../../../components/CopyButton'; +import SyncStatusIndicator from '../../../components/SyncStatusIndicator'; const DataListAction = styled(_DataListAction)` align-items: center; @@ -52,6 +52,14 @@ function InventoryListItem({ }, [inventory.id, inventory.name, fetchInventories]); const labelId = `check-action-${inventory.id}`; + + let syncStatus = 'disabled'; + if (inventory.isSourceSyncRunning) { + syncStatus = 'syncing'; + } else if (inventory.has_inventory_sources) { + syncStatus = + inventory.inventory_sources_with_failures > 0 ? 'error' : 'success'; + } return ( + + + , + {inventory.name} diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js new file mode 100644 index 0000000000..eea15f9a2c --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js @@ -0,0 +1,88 @@ +import { useState, useEffect } from 'react'; +import useWebsocket from '../../../util/useWebsocket'; +import useThrottle from '../../../util/useThrottle'; + +export default function useWsProjects( + initialInventories, + fetchInventoriesById +) { + const [inventories, setInventories] = useState(initialInventories); + const [inventoriesToFetch, setInventoriesToFetch] = useState([]); + const throttledInventoriesToFetch = useThrottle(inventoriesToFetch, 5000); + const lastMessage = useWebsocket({ + inventories: ['status_changed'], + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); + + useEffect(() => { + setInventories(initialInventories); + }, [initialInventories]); + + const enqueueId = id => { + if (!inventoriesToFetch.includes(id)) { + setInventoriesToFetch(ids => ids.concat(id)); + } + }; + useEffect( + function fetchUpdatedInventories() { + (async () => { + if (!throttledInventoriesToFetch.length) { + return; + } + setInventoriesToFetch([]); + const newInventories = await fetchInventoriesById( + throttledInventoriesToFetch + ); + const updated = [...inventories]; + newInventories.forEach(inventory => { + const index = inventories.findIndex(i => i.id === inventory.id); + if (index === -1) { + return; + } + updated[index] = inventory; + }); + setInventories(updated); + })(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [throttledInventoriesToFetch, fetchInventoriesById] + ); + + useEffect( + function processWsMessage() { + if ( + !lastMessage?.inventory_id || + lastMessage.type !== 'inventory_update' + ) { + return; + } + const index = inventories.findIndex( + p => p.id === lastMessage.inventory_id + ); + if (index === -1) { + return; + } + + if (!['pending', 'waiting', 'running'].includes(lastMessage.status)) { + enqueueId(lastMessage.inventory_id); + return; + } + + const inventory = inventories[index]; + const updatedInventory = { + ...inventory, + isSourceSyncRunning: true, + }; + setInventories([ + ...inventories.slice(0, index), + updatedInventory, + ...inventories.slice(index + 1), + ]); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps, + [lastMessage] + ); + + return inventories; +} diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.test.jsx new file mode 100644 index 0000000000..196166add6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.test.jsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import WS from 'jest-websocket-mock'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import useWsInventories from './useWsInventories'; + +/* + 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({ inventories, fetch }) { + const syncedJobs = useWsInventories(inventories, fetch); + return ; +} + +describe('useWsInventories 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 inventories list', () => { + const inventories = [{ id: 1 }]; + wrapper = mountWithContexts(); + + expect(wrapper.find('TestInner').prop('inventories')).toEqual(inventories); + WS.clean(); + }); + + test('should establish websocket connection', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const inventories = [{ id: 1 }]; + await act(async () => { + wrapper = await mountWithContexts(); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + inventories: ['status_changed'], + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + WS.clean(); + }); + + test('should update inventory sync status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const inventories = [{ id: 1 }]; + await act(async () => { + wrapper = await mountWithContexts(); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + inventories: ['status_changed'], + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + act(() => { + mockServer.send( + JSON.stringify({ + inventory_id: 1, + type: 'inventory_update', + status: 'running', + }) + ); + }); + wrapper.update(); + + expect( + wrapper.find('TestInner').prop('inventories')[0].isSourceSyncRunning + ).toEqual(true); + WS.clean(); + }); + + test('should fetch fresh inventory after sync runs', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + const inventories = [{ id: 1 }]; + const fetch = jest.fn(() => []); + await act(async () => { + wrapper = await mountWithContexts( + + ); + }); + + await mockServer.connected; + await act(async () => { + mockServer.send( + JSON.stringify({ + inventory_id: 1, + type: 'inventory_update', + status: 'successful', + }) + ); + }); + + expect(fetch).toHaveBeenCalledWith([1]); + WS.clean(); + }); +}); diff --git a/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js index a5c46319ba..38303c9ed3 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js +++ b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js @@ -1,9 +1,12 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect } from 'react'; +import useWebsocket from '../../../util/useWebsocket'; export default function useWsProjects(initialProjects) { const [projects, setProjects] = useState(initialProjects); - const [lastMessage, setLastMessage] = useState(null); - const ws = useRef(null); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); useEffect(() => { setProjects(initialProjects); @@ -37,49 +40,5 @@ export default function useWsProjects(initialProjects) { ]); }, [lastMessage]); // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { - ws.current = new WebSocket(`wss://${window.location.host}/websocket/`); - - const connect = () => { - const xrftoken = `; ${document.cookie}` - .split('; csrftoken=') - .pop() - .split(';') - .shift(); - ws.current.send( - JSON.stringify({ - xrftoken, - groups: { - jobs: ['status_changed'], - control: ['limit_reached_1'], - }, - }) - ); - }; - ws.current.onopen = connect; - - ws.current.onmessage = e => { - setLastMessage(JSON.parse(e.data)); - }; - - ws.current.onclose = e => { - // eslint-disable-next-line no-console - console.debug('Socket closed. Reconnecting...', e); - setTimeout(() => { - connect(); - }, 1000); - }; - - ws.current.onerror = err => { - // eslint-disable-next-line no-console - console.debug('Socket error: ', err, 'Disconnecting...'); - ws.current.close(); - }; - - return () => { - ws.current.close(); - }; - }, []); - return projects; } diff --git a/awx/ui_next/src/util/useWebsocket.js b/awx/ui_next/src/util/useWebsocket.js new file mode 100644 index 0000000000..c04d086f35 --- /dev/null +++ b/awx/ui_next/src/util/useWebsocket.js @@ -0,0 +1,49 @@ +import { useState, useEffect, useRef } from 'react'; + +export default function useWebsocket(subscribeGroups) { + const [lastMessage, setLastMessage] = useState(null); + const ws = useRef(null); + + useEffect(function setupSocket() { + ws.current = new WebSocket(`wss://${window.location.host}/websocket/`); + + const connect = () => { + const xrftoken = `; ${document.cookie}` + .split('; csrftoken=') + .pop() + .split(';') + .shift(); + ws.current.send( + JSON.stringify({ + xrftoken, + groups: subscribeGroups, + }) + ); + }; + ws.current.onopen = connect; + + ws.current.onmessage = e => { + setLastMessage(JSON.parse(e.data)); + }; + + ws.current.onclose = e => { + // eslint-disable-next-line no-console + console.debug('Socket closed. Reconnecting...', e); + setTimeout(() => { + connect(); + }, 1000); + }; + + ws.current.onerror = err => { + // eslint-disable-next-line no-console + console.debug('Socket error: ', err, 'Disconnecting...'); + ws.current.close(); + }; + + return () => { + ws.current.close(); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + return lastMessage; +}