diff --git a/awx/ui/src/api/models/Inventories.js b/awx/ui/src/api/models/Inventories.js index 37654478a7..4fd145e178 100644 --- a/awx/ui/src/api/models/Inventories.js +++ b/awx/ui/src/api/models/Inventories.js @@ -13,7 +13,7 @@ class Inventories extends InstanceGroupsMixin(Base) { this.readGroups = this.readGroups.bind(this); this.readGroupsOptions = this.readGroupsOptions.bind(this); this.promoteGroup = this.promoteGroup.bind(this); - this.readSourceInventories = this.readSourceInventories.bind(this); + this.readInputInventories = this.readInputInventories.bind(this); } readAccessList(id, params) { @@ -73,7 +73,7 @@ class Inventories extends InstanceGroupsMixin(Base) { }); } - readSourceInventories(inventoryId, params) { + readInputInventories(inventoryId, params) { return this.http.get(`${this.baseUrl}${inventoryId}/input_inventories/`, { params, }); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js index 914e86b0b1..6108dc2330 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js @@ -5,54 +5,103 @@ import { t } from '@lingui/macro'; import { Button, Chip, + Label, + LabelGroup, TextList, TextListItem, TextListItemVariants, TextListVariants, + Tooltip, } from '@patternfly/react-core'; +import { InventoriesAPI, ConstructedInventoriesAPI } from 'api'; +import { Inventory } from 'types'; +import { formatDateString } from 'util/dates'; +import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; +import useRequest, { useDismissableError } from 'hooks/useRequest'; import AlertModal from 'components/AlertModal'; import { CardBody, CardActionsRow } from 'components/Card'; -import { DetailList, Detail, UserDateDetail } from 'components/DetailList'; +import ChipGroup from 'components/ChipGroup'; import { VariablesDetail } from 'components/CodeEditor'; -import DeleteButton from 'components/DeleteButton'; -import ErrorDetail from 'components/ErrorDetail'; import ContentError from 'components/ContentError'; import ContentLoading from 'components/ContentLoading'; -import ChipGroup from 'components/ChipGroup'; -import Popover from 'components/Popover'; -import { InventoriesAPI, ConstructedInventoriesAPI } from 'api'; -import useRequest, { useDismissableError } from 'hooks/useRequest'; -import { Inventory } from 'types'; -import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; +import { DetailList, Detail, UserDateDetail } from 'components/DetailList'; +import DeleteButton from 'components/DeleteButton'; +import ErrorDetail from 'components/ErrorDetail'; import InstanceGroupLabels from 'components/InstanceGroupLabels'; +import JobCancelButton from 'components/JobCancelButton'; +import Popover from 'components/Popover'; +import StatusLabel from 'components/StatusLabel'; +import ConstructedInventorySyncButton from './ConstructedInventorySyncButton'; +import useWsInventorySourcesDetails from '../shared/useWsInventorySourcesDetails'; import getHelpText from '../shared/Inventory.helptext'; +function JobStatusLabel({ job }) { + if (!job) { + return null; + } + + return ( + +
{t`MOST RECENT SYNC`}
+
+ {t`JOB ID:`} {job.id} +
+
+ {t`STATUS:`} {job.status.toUpperCase()} +
+ {job.finished && ( +
+ {t`FINISHED:`} {formatDateString(job.finished)} +
+ )} + + } + key={job.id} + > + + + +
+ ); +} + function ConstructedInventoryDetail({ inventory }) { const history = useHistory(); const helpText = getHelpText(); const { - result: { instanceGroups, sourceInventories, actions }, + result: { instanceGroups, inputInventories, inventorySource, actions }, request: fetchRelatedDetails, error: contentError, isLoading, } = useRequest( useCallback(async () => { - const [response, sourceInvResponse, options] = await Promise.all([ + const [ + instanceGroupsResponse, + inputInventoriesResponse, + inventorySourceResponse, + optionsResponse, + ] = await Promise.all([ InventoriesAPI.readInstanceGroups(inventory.id), - InventoriesAPI.readSourceInventories(inventory.id), - ConstructedInventoriesAPI.readOptions(inventory.id), + InventoriesAPI.readInputInventories(inventory.id), + InventoriesAPI.readSources(inventory.id), + ConstructedInventoriesAPI.readOptions(), ]); return { - instanceGroups: response.data.results, - sourceInventories: sourceInvResponse.data.results, - actions: options.data.actions.GET, + instanceGroups: instanceGroupsResponse.data.results, + inputInventories: inputInventoriesResponse.data.results, + inventorySource: inventorySourceResponse.data.results[0], + actions: optionsResponse.data.actions.GET, }; }, [inventory.id]), { instanceGroups: [], - sourceInventories: [], + inputInventories: [], + inventorySource: {}, actions: {}, isLoading: true, } @@ -62,6 +111,16 @@ function ConstructedInventoryDetail({ inventory }) { fetchRelatedDetails(); }, [fetchRelatedDetails]); + const wsInventorySource = useWsInventorySourcesDetails(inventorySource); + const inventorySourceSyncJob = + wsInventorySource.summary_fields?.current_job || + wsInventorySource.summary_fields?.last_job || + null; + const wsInventory = { + ...inventory, + ...wsInventorySource?.summary_fields?.inventory, + }; + const { request: deleteInventory, error: deleteError } = useRequest( useCallback(async () => { await InventoriesAPI.destroy(inventory.id); @@ -71,9 +130,6 @@ function ConstructedInventoryDetail({ inventory }) { const { error, dismissError } = useDismissableError(deleteError); - const { organization, user_capabilities: userCapabilities } = - inventory.summary_fields; - const deleteDetailsRequests = relatedResourceDeleteRequests.inventory(inventory); @@ -93,6 +149,14 @@ function ConstructedInventoryDetail({ inventory }) { value={inventory.name} dataCy="constructed-inventory-name" /> + + ) + } + /> - {organization.name} + + {inventory.summary_fields?.organization.name} } /> @@ -144,7 +210,7 @@ function ConstructedInventoryDetail({ inventory }) { /> @@ -204,26 +270,29 @@ function ConstructedInventoryDetail({ inventory }) { /> - {sourceInventories?.map((sourceInventory) => ( - + {inputInventories?.map((inputInventory) => ( + ))} - + } - isEmpty={sourceInventories?.length === 0} + isEmpty={inputInventories?.length === 0} /> - {userCapabilities.edit && ( + {inventory?.summary_fields?.user_capabilities?.edit && ( + + {startError && ( + + {t`Failed to sync constructed inventory source`} + + + )} + + ); +} + +ConstructedInventorySyncButton.propTypes = { + inventoryId: PropTypes.number.isRequired, +}; + +export default ConstructedInventorySyncButton; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.test.js new file mode 100644 index 0000000000..75a5900abb --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.test.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { InventoriesAPI } from 'api'; +import ConstructedInventorySyncButton from './ConstructedInventorySyncButton'; +import { render, fireEvent, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +jest.mock('../../../api'); + +const inventory = { id: 100, name: 'Constructed Inventory' }; + +describe('', () => { + const Component = () => ( + + ); + + test('should render start sync button', () => { + render(); + expect( + screen.getByRole('button', { name: 'Start inventory source sync' }) + ).toBeInTheDocument(); + }); + + test('should make expected api request on sync', async () => { + render(); + const syncButton = screen.queryByText('Sync'); + fireEvent.click(syncButton); + await waitFor(() => + expect(InventoriesAPI.syncAllSources).toHaveBeenCalledWith(100) + ); + }); + + test('should show alert modal on throw', async () => { + InventoriesAPI.syncAllSources.mockRejectedValueOnce(new Error()); + render(); + await waitFor(() => { + const syncButton = screen.queryByText('Sync'); + fireEvent.click(syncButton); + }); + expect(screen.getByRole('dialog', { name: 'Alert modal Error!' })); + }); +}); diff --git a/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js b/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js index 87e9ece5cd..af657b4fbe 100644 --- a/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js +++ b/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js @@ -31,7 +31,7 @@ import { formatDateString } from 'util/dates'; import Popover from 'components/Popover'; import { VERBOSITY } from 'components/VerbositySelectField'; import InventorySourceSyncButton from '../shared/InventorySourceSyncButton'; -import useWsInventorySourcesDetails from '../InventorySources/useWsInventorySourcesDetails'; +import useWsInventorySourcesDetails from '../shared/useWsInventorySourcesDetails'; import getHelpText from '../shared/Inventory.helptext'; function InventorySourceDetail({ inventorySource }) { diff --git a/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.js b/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.js deleted file mode 100644 index e93f28f58b..0000000000 --- a/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.js +++ /dev/null @@ -1,42 +0,0 @@ -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/shared/useWsInventorySourcesDetails.js b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js new file mode 100644 index 0000000000..e010b8916a --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js @@ -0,0 +1,58 @@ +import { useState, useEffect } from 'react'; +import useWebsocket from 'hooks/useWebsocket'; +import { InventorySourcesAPI } from 'api'; + +export default function useWsInventorySourcesDetails(initialSource) { + const [source, setSource] = useState(initialSource); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); + + useEffect(() => { + setSource(initialSource); + }, [initialSource]); + + useEffect( + () => { + if ( + !lastMessage?.unified_job_id || + !lastMessage?.inventory_source_id || + lastMessage.type !== 'inventory_update' + ) { + return; + } + + if ( + ['successful', 'failed', 'error', 'cancelled'].includes( + lastMessage.status + ) + ) { + fetchSource(); + } + setSource(updateSource(source, lastMessage)); + }, + [lastMessage] // eslint-disable-line react-hooks/exhaustive-deps + ); + + async function fetchSource() { + const { data } = await InventorySourcesAPI.readDetail(source.id); + setSource(data); + } + + return source; +} + +function updateSource(source, message) { + return { + ...source, + summary_fields: { + ...source.summary_fields, + current_job: { + id: message.unified_job_id, + status: message.status, + finished: message.finished, + }, + }, + }; +} diff --git a/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.test.js b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js similarity index 81% rename from awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.test.js rename to awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js index 25fb97850b..d1f1e17009 100644 --- a/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.test.js +++ b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js @@ -1,9 +1,12 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import WS from 'jest-websocket-mock'; +import { InventorySourcesAPI } from 'api'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import useWsInventorySourceDetails from './useWsInventorySourcesDetails'; +jest.mock('../../../api/models/InventorySources'); + function TestInner() { return
; } @@ -111,6 +114,27 @@ describe('useWsProject', () => { status: 'running', finished: null, }); + + expect(InventorySourcesAPI.readDetail).toHaveBeenCalledTimes(0); + InventorySourcesAPI.readDetail.mockResolvedValue({ + data: {}, + }); + await act(async () => { + mockServer.send( + JSON.stringify({ + group_name: 'jobs', + inventory_id: 1, + status: 'successful', + type: 'inventory_update', + unified_job_id: 2, + unified_job_template_id: 1, + inventory_source_id: 1, + }) + ); + }); + expect(InventorySourcesAPI.readDetail).toHaveBeenCalledTimes(1); + + jest.clearAllMocks(); WS.clean(); }); });