From 53baea4c6cd1927a9d944237edfcee9b8c026c99 Mon Sep 17 00:00:00 2001 From: nixocio Date: Mon, 23 Aug 2021 13:59:37 -0400 Subject: [PATCH] Add websockets to Inventory Source Details Add websockets to Inventory Source Details See: https://github.com/ansible/awx/issues/9013 --- .../InventorySourceDetail.js | 97 ++++++++++++--- .../InventorySourceDetail.test.js | 23 ++++ .../useWsInventorySourcesDetails.js | 42 +++++++ .../useWsInventorySourcesDetails.test.js | 116 ++++++++++++++++++ 4 files changed, 259 insertions(+), 19 deletions(-) create mode 100644 awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.js create mode 100644 awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.test.js diff --git a/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js b/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js index cd94f7ec15..93e9f51a57 100644 --- a/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js +++ b/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js @@ -7,22 +7,27 @@ import { TextListItem, TextListVariants, TextListItemVariants, + Tooltip, } from '@patternfly/react-core'; import AlertModal from 'components/AlertModal'; -import { CardBody, CardActionsRow } from 'components/Card'; -import { VariablesDetail } from 'components/CodeEditor'; import ContentError from 'components/ContentError'; import ContentLoading from 'components/ContentLoading'; import CredentialChip from 'components/CredentialChip'; import DeleteButton from 'components/DeleteButton'; -import ExecutionEnvironmentDetail from 'components/ExecutionEnvironmentDetail'; -import { DetailList, Detail, UserDateDetail } from 'components/DetailList'; import ErrorDetail from 'components/ErrorDetail'; +import ExecutionEnvironmentDetail from 'components/ExecutionEnvironmentDetail'; +import JobCancelButton from 'components/JobCancelButton'; +import StatusLabel from 'components/StatusLabel'; +import { CardBody, CardActionsRow } from 'components/Card'; +import { DetailList, Detail, UserDateDetail } from 'components/DetailList'; +import { VariablesDetail } from 'components/CodeEditor'; import useRequest from 'hooks/useRequest'; import { InventorySourcesAPI } from 'api'; import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; import useIsMounted from 'hooks/useIsMounted'; +import { formatDateString } from 'util/dates'; import InventorySourceSyncButton from '../shared/InventorySourceSyncButton'; +import useWsInventorySourcesDetails from '../InventorySources/useWsInventorySourcesDetails'; function InventorySourceDetail({ inventorySource }) { const { @@ -44,17 +49,20 @@ function InventorySourceDetail({ inventorySource }) { enabled_var, enabled_value, host_filter, - summary_fields: { - created_by, - credentials, - inventory, - modified_by, - organization, - source_project, - user_capabilities, - execution_environment, - }, - } = inventorySource; + summary_fields, + } = useWsInventorySourcesDetails(inventorySource); + + const { + created_by, + credentials, + inventory, + modified_by, + organization, + source_project, + user_capabilities, + execution_environment, + } = summary_fields; + const [deletionError, setDeletionError] = useState(false); const history = useHistory(); const isMounted = useIsMounted(); @@ -144,10 +152,51 @@ function InventorySourceDetail({ inventorySource }) { return ; } + const generateLastJobTooltip = (job) => ( + <> +
{t`MOST RECENT SYNC`}
+
+ {t`JOB ID:`} {job.id} +
+
+ {t`STATUS:`} {job.status.toUpperCase()} +
+ {job.finished && ( +
+ {t`FINISHED:`} {formatDateString(job.finished)} +
+ )} + + ); + + let job = null; + + if (summary_fields?.current_job) { + job = summary_fields.current_job; + } else if (summary_fields?.last_job) { + job = summary_fields.last_job; + } + return ( - + + + + + + + ) + } + /> {organization && ( @@ -226,9 +275,18 @@ function InventorySourceDetail({ inventorySource }) { {t`Edit`} )} - {user_capabilities?.start && ( - - )} + {user_capabilities?.start && + (['new', 'running', 'pending', 'waiting'].includes(job?.status) ? ( + + ) : ( + + ))} {user_capabilities?.delete && ( {t`Delete`} diff --git a/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.js b/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.js index fb27558d88..386e8f3a3d 100644 --- a/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.js +++ b/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.js @@ -56,6 +56,29 @@ describe('InventorySourceDetail', () => { jest.clearAllMocks(); }); + test('should render cancel button while job is running', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect(wrapper.find('InventorySourceDetail')).toHaveLength(1); + + expect(wrapper.find('JobCancelButton').length).toBe(1); + }); + test('should render expected details', async () => { await act(async () => { wrapper = mountWithContexts( diff --git a/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.js b/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.js new file mode 100644 index 0000000000..e93f28f58b --- /dev/null +++ b/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.js @@ -0,0 +1,42 @@ +import { useState, useEffect } from 'react'; +import useWebsocket from 'hooks/useWebsocket'; + +export default function useWsInventorySourcesDetails(initialSources) { + const [sources, setSources] = useState(initialSources); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); + + useEffect(() => { + setSources(initialSources); + }, [initialSources]); + + useEffect( + () => { + if ( + !lastMessage?.unified_job_id || + !lastMessage?.inventory_source_id || + lastMessage.type !== 'inventory_update' + ) { + return; + } + const updateSource = { + ...sources, + summary_fields: { + ...sources.summary_fields, + current_job: { + id: lastMessage.unified_job_id, + status: lastMessage.status, + finished: lastMessage.finished, + }, + }, + }; + + setSources(updateSource); + }, + [lastMessage] // eslint-disable-line react-hooks/exhaustive-deps + ); + + return sources; +} diff --git a/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.test.js b/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.test.js new file mode 100644 index 0000000000..25fb97850b --- /dev/null +++ b/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.test.js @@ -0,0 +1,116 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import WS from 'jest-websocket-mock'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import useWsInventorySourceDetails from './useWsInventorySourcesDetails'; + +function TestInner() { + return
; +} +function Test({ inventorySource }) { + const synced = useWsInventorySourceDetails(inventorySource); + return ; +} + +describe('useWsProject', () => { + let wrapper; + + test('should return inventory source detail', async () => { + const inventorySource = { id: 1 }; + await act(async () => { + wrapper = await mountWithContexts( + + ); + }); + + expect(wrapper.find('TestInner').prop('inventorySource')).toEqual( + inventorySource + ); + WS.clean(); + }); + + test('should establish websocket connection', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('ws://localhost/websocket/'); + + const inventorySource = { 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 inventory source status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('ws://localhost/websocket/'); + + const inventorySource = { + id: 1, + summary_fields: { + current_job: { + id: 1, + status: 'running', + finished: null, + }, + }, + }; + 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'], + }, + }) + ); + expect( + wrapper.find('TestInner').prop('inventorySource').summary_fields + .current_job.status + ).toEqual('running'); + + await act(async () => { + mockServer.send( + JSON.stringify({ + group_name: 'jobs', + inventory_id: 1, + status: 'pending', + type: 'inventory_source', + unified_job_id: 2, + unified_job_template_id: 1, + inventory_source_id: 1, + }) + ); + }); + wrapper.update(); + + expect( + wrapper.find('TestInner').prop('inventorySource').summary_fields + .current_job + ).toEqual({ + id: 1, + status: 'running', + finished: null, + }); + WS.clean(); + }); +});