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