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..e0aa888f97 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,8 @@ function InventoryList({ i18n }) {
fetchInventories();
}, [fetchInventories]);
+ const inventories = useWsInventories(results);
+
const isAllSelected =
selected.length === inventories.length && selected.length > 0;
const {
diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryListItem.jsx
index 9d272ace69..eb091495c7 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,12 @@ function InventoryListItem({
}, [inventory.id, inventory.name, fetchInventories]);
const labelId = `check-action-${inventory.id}`;
+
+ let syncStatus = 'disabled';
+ 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..29d438d3bf
--- /dev/null
+++ b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js
@@ -0,0 +1,95 @@
+import { useState, useEffect, useRef } from 'react';
+
+export default function useWsProjects(initialInventories) {
+ const [inventories, setInventories] = useState(initialInventories);
+ const [lastMessage, setLastMessage] = useState(null);
+ const ws = useRef(null);
+
+ useEffect(() => {
+ setInventories(initialInventories);
+ }, [initialInventories]);
+
+ // const messageExample = {
+ // unified_job_id: 533,
+ // status: 'pending',
+ // type: 'inventory_update',
+ // inventory_source_id: 53,
+ // inventory_id: 5,
+ // group_name: 'jobs',
+ // unified_job_template_id: 53,
+ // };
+ useEffect(() => {
+ if (!lastMessage?.unified_job_id || lastMessage.type !== 'project_update') {
+ return;
+ }
+ const index = inventories.findIndex(p => p.id === lastMessage.project_id);
+ if (index === -1) {
+ return;
+ }
+
+ const inventory = inventories[index];
+ const updatedProject = {
+ ...inventory,
+ summary_fields: {
+ ...inventory.summary_fields,
+ // last_job: {
+ // id: lastMessage.unified_job_id,
+ // status: lastMessage.status,
+ // finished: lastMessage.finished,
+ // },
+ },
+ };
+ setInventories([
+ ...inventories.slice(0, index),
+ updatedProject,
+ ...inventories.slice(index + 1),
+ ]);
+ }, [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: {
+ inventories: ['status_changed'],
+ 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 inventories;
+}