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;
+}