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