0 ? fetchBulkParams() : null}
@@ -472,7 +504,11 @@ const JobInvocationHostTable = ({
| {columns[k].wrapper(result)} |
))}
-
+
|
{},
- statusLabel: undefined,
+ jobFinished: false,
};
export default JobInvocationHostTable;
diff --git a/webpack/JobInvocationDetail/JobInvocationSelectors.js b/webpack/JobInvocationDetail/JobInvocationSelectors.js
index e43bfd208..26835ada9 100644
--- a/webpack/JobInvocationDetail/JobInvocationSelectors.js
+++ b/webpack/JobInvocationDetail/JobInvocationSelectors.js
@@ -5,18 +5,17 @@ import {
} from 'foremanReact/redux/API/APISelectors';
import {
JOB_INVOCATION_KEY,
- GET_TASK,
+ JOB_INVOCATION_HOSTS,
GET_TEMPLATE_INVOCATION,
LIST_TEMPLATE_INVOCATIONS,
+ CURRENT_PERMISSIONS,
} from './JobInvocationConstants';
export const selectItems = state =>
selectAPIResponse(state, JOB_INVOCATION_KEY);
-export const selectTask = state => selectAPIResponse(state, GET_TASK);
-
export const selectTaskCancelable = state =>
- selectTask(state).available_actions?.cancellable || false;
+ selectItems(state).task?.cancellable || false;
export const selectTemplateInvocation = hostID => state =>
selectAPIResponse(state, `${GET_TEMPLATE_INVOCATION}_${hostID}`);
@@ -27,3 +26,21 @@ export const selectTemplateInvocationStatus = hostID => state =>
export const selectTemplateInvocationList = state =>
selectAPIResponse(state, LIST_TEMPLATE_INVOCATIONS)
?.template_invocations_task_by_hosts;
+
+export const selectHostFromJobInvocationHosts = hostID => state =>
+ selectAPIResponse(state, JOB_INVOCATION_HOSTS)?.results?.find(
+ h => h.id === hostID
+ );
+
+export const selectCurrentPermisions = state =>
+ selectAPIResponse(state, CURRENT_PERMISSIONS);
+
+export const selectHasPermission = permissionRequired => state => {
+ const status = selectAPIStatus(state, CURRENT_PERMISSIONS);
+ const selectCurrentPermissions = selectCurrentPermisions(state)?.results;
+ return status === APIStatus.RESOLVED
+ ? selectCurrentPermissions?.some(
+ permission => permission.name === permissionRequired
+ )
+ : false;
+};
diff --git a/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js b/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js
index 5fadf906b..0e9805483 100644
--- a/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js
+++ b/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js
@@ -136,7 +136,7 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => {
ouiaId="change-enabled-recurring-dropdown-item"
onClick={() =>
dispatch(
- enableRecurringLogic(recurrence?.id, recurringEnabled, jobId)
+ enableRecurringLogic(recurrence?.id, recurringEnabled)
)
}
key="change-enabled-recurring"
@@ -154,7 +154,7 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => {
- dispatch(cancelRecurringLogic(recurrence?.id, jobId))
+ dispatch(cancelRecurringLogic(recurrence?.id))
}
key="cancel-recurring"
component="button"
@@ -168,7 +168,7 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => {
,
]
: [],
- [recurrence, recurringEnabled, canEditRecurringLogic, dispatch, jobId]
+ [recurrence, recurringEnabled, canEditRecurringLogic, dispatch]
);
const dropdownItems = useMemo(
diff --git a/webpack/JobInvocationDetail/TemplateInvocation.js b/webpack/JobInvocationDetail/TemplateInvocation.js
index 9045be218..ca1616f36 100644
--- a/webpack/JobInvocationDetail/TemplateInvocation.js
+++ b/webpack/JobInvocationDetail/TemplateInvocation.js
@@ -67,7 +67,6 @@ export const TemplateInvocation = ({
showCommand,
setShowCommand,
}) => {
- const intervalRef = useRef(null);
const templateURL = showTemplateInvocationUrl(hostID, jobID);
const hostDetailsPageUrl = useForemanHostDetailsPageUrl();
@@ -75,49 +74,48 @@ export const TemplateInvocation = ({
const response = useSelector(selectTemplateInvocation(hostID));
const dispatch = useDispatch();
+ const timeoutRef = useRef(null);
const responseRef = useRef(response);
- useEffect(() => {
- responseRef.current = response;
- }, [response]);
+ responseRef.current = response;
useEffect(() => {
+ let cancelled = false;
const dispatchFetch = () => {
dispatch(
APIActions.get({
url: templateURL,
key: `${GET_TEMPLATE_INVOCATION}_${hostID}`,
+ handleSuccess: ({ data }) => {
+ if (cancelled) return;
+ const finished = data?.finished ?? false;
+ // eslint-disable-next-line camelcase
+ const autoRefresh = data?.auto_refresh || false;
+ if (!finished && autoRefresh) {
+ timeoutRef.current = setTimeout(dispatchFetch, 5000);
+ }
+ },
+ handleError: () => {
+ if (cancelled) return;
+ timeoutRef.current = null;
+ },
})
);
};
- if (intervalRef.current) {
- clearInterval(intervalRef.current);
- intervalRef.current = null;
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
}
if (isExpanded) {
- if (isEmpty(responseRef.current)) {
+ if (responseRef.current?.finished !== true) {
dispatchFetch();
}
-
- intervalRef.current = setInterval(() => {
- const latestResponse = responseRef.current;
- const finished = latestResponse?.finished ?? true;
- // eslint-disable-next-line camelcase
- const autoRefresh = latestResponse?.auto_refresh || false;
-
- if (!finished && autoRefresh) {
- dispatchFetch();
- } else if (intervalRef.current) {
- clearInterval(intervalRef.current);
- }
- }, 5000);
}
return () => {
- if (intervalRef.current) {
- clearInterval(intervalRef.current);
- }
+ cancelled = true;
+ clearTimeout(timeoutRef.current);
};
}, [isExpanded, dispatch, templateURL, hostID]);
diff --git a/webpack/JobInvocationDetail/TemplateInvocationComponents/TemplateActionButtons.js b/webpack/JobInvocationDetail/TemplateInvocationComponents/TemplateActionButtons.js
index f9242f646..1ee915e46 100644
--- a/webpack/JobInvocationDetail/TemplateInvocationComponents/TemplateActionButtons.js
+++ b/webpack/JobInvocationDetail/TemplateInvocationComponents/TemplateActionButtons.js
@@ -6,7 +6,7 @@ import { ActionsColumn } from '@patternfly/react-table';
import { APIActions } from 'foremanReact/redux/API';
import { addToast } from 'foremanReact/components/ToastsList';
import { translate as __ } from 'foremanReact/common/I18n';
-import { selectTemplateInvocationList } from '../JobInvocationSelectors';
+import { selectHostFromJobInvocationHosts } from '../JobInvocationSelectors';
import './index.scss';
const actions = ({
@@ -81,11 +81,12 @@ const actions = ({
},
});
-export const RowActions = ({ hostID, jobID }) => {
+export const RowActions = ({ hostID, jobID, permissions: permissionsProp }) => {
const dispatch = useDispatch();
- const response = useSelector(selectTemplateInvocationList)?.[hostID];
- if (!response?.permissions) return null;
- const { task, permissions } = response;
+ const response = useSelector(selectHostFromJobInvocationHosts(hostID));
+ const permissions = permissionsProp ?? response?.permissions;
+ if (!permissions) return null;
+ const { task } = response || {};
const { id: taskID, cancellable: taskCancellable } = task || {};
const getActions = actions({
taskID,
@@ -216,4 +217,13 @@ TemplateActionButtons.defaultProps = {
RowActions.propTypes = {
hostID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
jobID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ permissions: PropTypes.shape({
+ view_foreman_tasks: PropTypes.bool,
+ cancel_job_invocations: PropTypes.bool,
+ execute_jobs: PropTypes.bool,
+ }),
+};
+
+RowActions.defaultProps = {
+ permissions: null,
};
diff --git a/webpack/JobInvocationDetail/__tests__/JobInvocationHostTablePolling.test.js b/webpack/JobInvocationDetail/__tests__/JobInvocationHostTablePolling.test.js
new file mode 100644
index 000000000..5aa042d51
--- /dev/null
+++ b/webpack/JobInvocationDetail/__tests__/JobInvocationHostTablePolling.test.js
@@ -0,0 +1,317 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import { render, act } from '@testing-library/react';
+import '@testing-library/jest-dom/extend-expect';
+import { createStore, applyMiddleware } from 'redux';
+import thunk from 'redux-thunk';
+import * as api from 'foremanReact/redux/API';
+import JobInvocationHostTable from '../JobInvocationHostTable';
+
+jest.mock(
+ 'foremanReact/components/PF4/TableIndexPage/TableIndexPage',
+ () => jest.fn(() => ),
+ { virtual: true }
+);
+jest.mock(
+ 'foremanReact/components/PF4/TableIndexPage/Table/Table',
+ () => ({ Table: jest.fn(() => ) }),
+ { virtual: true }
+);
+jest.mock(
+ 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks',
+ () => ({
+ useBulkSelect: jest.fn(() => ({
+ updateSearchQuery: jest.fn(),
+ fetchBulkParams: jest.fn(() => ''),
+ inclusionSet: new Set(),
+ exclusionSet: new Set(),
+ selectAll: jest.fn(),
+ selectPage: jest.fn(),
+ selectNone: jest.fn(),
+ selectedCount: 0,
+ selectOne: jest.fn(),
+ areAllRowsOnPageSelected: jest.fn(() => false),
+ areAllRowsSelected: jest.fn(() => false),
+ isSelected: jest.fn(() => false),
+ })),
+ useUrlParams: jest.fn(() => ({})),
+ }),
+ { virtual: true }
+);
+jest.mock(
+ 'foremanReact/components/PF4/TableIndexPage/Table/helpers',
+ () => ({ getPageStats: jest.fn(() => ({ pageRowCount: 0 })) }),
+ { virtual: true }
+);
+jest.mock(
+ 'foremanReact/components/PF4/TableIndexPage/RowSelectTd',
+ () => ({ RowSelectTd: jest.fn(() => null) }),
+ { virtual: true }
+);
+jest.mock(
+ 'foremanReact/components/PF4/TableIndexPage/Table/SelectAllCheckbox',
+ () => jest.fn(() => null),
+ { virtual: true }
+);
+jest.mock('foremanReact/constants', () => ({
+ getControllerSearchProps: jest.fn(() => ({
+ autocomplete: { url: '' },
+ })),
+}));
+jest.mock('foremanReact/Root/Context/ForemanContext', () => ({
+ useForemanSettings: jest.fn(() => ({ perPage: 20 })),
+ useForemanHostDetailsPageUrl: jest.fn(() => '/new/hosts/'),
+}));
+jest.mock('react-router-dom', () => ({
+ useHistory: jest.fn(() => ({ push: jest.fn() })),
+}));
+jest.mock('../CheckboxesActions', () => ({
+ CheckboxesActions: jest.fn(() => null),
+}));
+jest.mock('../DropdownFilter', () => jest.fn(() => null));
+jest.mock('../JobInvocationConstants', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({})),
+ JOB_INVOCATION_HOSTS: 'JOB_INVOCATION_HOSTS',
+ AWAITING_STATUS_FILTER: '(job_invocation.result = N/A)',
+ STATUS_UPPERCASE: {
+ RESOLVED: 'RESOLVED',
+ ERROR: 'ERROR',
+ PENDING: 'PENDING',
+ },
+ showTemplateInvocationUrl: jest.fn(() => ''),
+ templateInvocationPageUrl: jest.fn(() => ''),
+ GET_TEMPLATE_INVOCATION: 'GET_TEMPLATE_INVOCATION',
+}));
+jest.mock('../OpenAllInvocationsModal', () => ({
+ PopupAlert: jest.fn(() => null),
+}));
+
+const noop = () => {};
+const reducer = (state = {}) => state;
+const makeStore = () => createStore(reducer, applyMiddleware(thunk));
+
+const defaultProps = {
+ id: '42',
+ targeting: {},
+ initialFilter: 'all_statuses',
+ jobFinished: false,
+ onFilterUpdate: noop,
+};
+
+describe('JobInvocationHostTable polling', () => {
+ let apiGetSpy;
+
+ beforeEach(() => {
+ jest.useFakeTimers({ legacyFakeTimers: true });
+ jest
+ .requireMock('foremanReact/components/PF4/TableIndexPage/TableIndexPage')
+ .mockClear();
+ apiGetSpy = jest
+ .spyOn(api.APIActions, 'get')
+ .mockImplementation(({ handleSuccess, ...action }) => {
+ handleSuccess &&
+ handleSuccess({ data: { results: [], total: 0, subtotal: 0 } });
+ return { type: 'MOCK_GET', ...action };
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ apiGetSpy.mockRestore();
+ });
+
+ it('starts polling when filter becomes non-empty', () => {
+ const store = makeStore();
+ const { rerender } = render(
+
+
+
+ );
+
+ expect(apiGetSpy).not.toHaveBeenCalled();
+
+ rerender(
+
+
+
+ );
+
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+ });
+
+ const renderAndTriggerFetch = (store, props = {}) => {
+ const result = render(
+
+
+
+ );
+ result.rerender(
+
+
+
+ );
+ return result;
+ };
+
+ it('schedules next poll via setTimeout when job is not finished', () => {
+ const store = makeStore();
+ renderAndTriggerFetch(store);
+
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+
+ act(() => jest.advanceTimersByTime(5000));
+
+ expect(apiGetSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not schedule next poll when job is finished', () => {
+ const store = makeStore();
+ renderAndTriggerFetch(store, { jobFinished: true });
+
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+
+ act(() => jest.advanceTimersByTime(5000));
+
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('respects jobFinishedRef — stops polling when jobFinished prop becomes true between polls', () => {
+ const store = makeStore();
+ const { rerender } = renderAndTriggerFetch(store, { jobFinished: false });
+
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+
+ // Changing jobFinished triggers statusChanged, so filterApiCall runs once more.
+ // But jobFinishedRef is now true, so that call does NOT schedule another poll.
+ rerender(
+
+
+
+ );
+
+ expect(apiGetSpy).toHaveBeenCalledTimes(2);
+
+ act(() => jest.advanceTimersByTime(5000));
+
+ // No further calls — polling has stopped.
+ expect(apiGetSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('clears the polling timeout on unmount', () => {
+ const store = makeStore();
+ const { unmount } = renderAndTriggerFetch(store);
+
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+ unmount();
+
+ act(() => jest.advanceTimersByTime(5000));
+
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+ });
+
+ describe('include_permissions behaviour', () => {
+ const hostWithPermissions = {
+ id: 1,
+ permissions: {
+ view_foreman_tasks: true,
+ cancel_job_invocations: true,
+ execute_jobs: true,
+ },
+ };
+
+ it('sends include_permissions on the first (non-poll) call', () => {
+ const store = makeStore();
+ renderAndTriggerFetch(store);
+
+ expect(apiGetSpy.mock.calls[0][0].params).toMatchObject({
+ include_permissions: true,
+ });
+ });
+
+ it('omits include_permissions on poll calls', () => {
+ const store = makeStore();
+ renderAndTriggerFetch(store);
+
+ act(() => jest.advanceTimersByTime(5000));
+
+ expect(apiGetSpy.mock.calls[1][0].params).not.toHaveProperty(
+ 'include_permissions'
+ );
+ });
+
+ it('preserves permissions from the initial call in subsequent poll responses', () => {
+ apiGetSpy.mockImplementation(({ handleSuccess, params }) => {
+ handleSuccess &&
+ handleSuccess({
+ data: {
+ results: params.include_permissions ? [hostWithPermissions] : [{ id: 1 }],
+ total: 1,
+ subtotal: 1,
+ },
+ });
+ return { type: 'MOCK_GET' };
+ });
+
+ const store = makeStore();
+ renderAndTriggerFetch(store);
+
+ // Advance to the first poll — this call has no include_permissions,
+ // so the raw result has no permissions field.
+ act(() => jest.advanceTimersByTime(5000));
+
+ const MockTableIndexPage = jest.requireMock(
+ 'foremanReact/components/PF4/TableIndexPage/TableIndexPage'
+ );
+ const replacementResponse =
+ MockTableIndexPage.mock.calls[
+ MockTableIndexPage.mock.calls.length - 1
+ ][0].replacementResponse;
+
+ expect(replacementResponse.response.results[0].permissions).toEqual(
+ hostWithPermissions.permissions
+ );
+ });
+ });
+
+ it('uses the current id in the api url after id prop changes', () => {
+ const store = makeStore();
+ renderAndTriggerFetch(store, { id: '42' });
+
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+ expect(apiGetSpy.mock.calls[0][0].url).toContain('/42/');
+ });
+
+ it('re-fetches when id prop changes', () => {
+ const store = makeStore();
+ const { rerender } = renderAndTriggerFetch(store, { id: '42' });
+
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+ expect(apiGetSpy.mock.calls[0][0].url).toContain('/42/');
+
+ rerender(
+
+
+
+ );
+
+ expect(apiGetSpy).toHaveBeenCalledTimes(2);
+ expect(apiGetSpy.mock.calls[1][0].url).toContain('/99/');
+ });
+});
diff --git a/webpack/JobInvocationDetail/__tests__/JobInvocationPolling.test.js b/webpack/JobInvocationDetail/__tests__/JobInvocationPolling.test.js
new file mode 100644
index 000000000..3a7e07436
--- /dev/null
+++ b/webpack/JobInvocationDetail/__tests__/JobInvocationPolling.test.js
@@ -0,0 +1,187 @@
+import { createStore, applyMiddleware } from 'redux';
+import configureMockStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import * as api from 'foremanReact/redux/API';
+import {
+ getJobInvocation,
+ stopJobInvocationPolling,
+ enableRecurringLogic,
+} from '../JobInvocationActions';
+import { JOB_INVOCATION_KEY } from '../JobInvocationConstants';
+
+jest.spyOn(api, 'get');
+jest.useFakeTimers();
+
+const reducer = (state = {}) => state;
+const makeStore = () => createStore(reducer, applyMiddleware(thunk));
+const makeRef = () => ({ current: null });
+
+const runningData = { status_label: 'running' };
+const succeededData = { status_label: 'succeeded' };
+const failedData = { status_label: 'failed' };
+const cancelledData = { status_label: 'cancelled' };
+
+const setupGetMock = responseData => {
+ api.get.mockImplementation(({ handleSuccess, ...action }) => {
+ handleSuccess && handleSuccess({ data: responseData });
+ return { type: 'get', ...action };
+ });
+};
+
+const url = '/api/job_invocations/1';
+
+describe('job invocation polling', () => {
+ let ref;
+
+ beforeEach(() => {
+ ref = makeRef();
+ api.get.mockReset();
+ jest.clearAllTimers();
+ });
+
+ it('sends include_permissions and include_hosts on the initial fetch', () => {
+ setupGetMock(succeededData);
+ const store = makeStore();
+
+ store.dispatch(getJobInvocation(url, ref));
+
+ expect(api.get).toHaveBeenCalledTimes(1);
+ expect(api.get.mock.calls[0][0]).toMatchObject({
+ key: JOB_INVOCATION_KEY,
+ url,
+ params: { include_hosts: false, include_permissions: true },
+ });
+ });
+
+ it('schedules the next poll when job is still running', () => {
+ setupGetMock(runningData);
+ const store = makeStore();
+
+ store.dispatch(getJobInvocation(url, ref));
+ expect(api.get).toHaveBeenCalledTimes(1);
+
+ jest.advanceTimersByTime(5000);
+ expect(api.get).toHaveBeenCalledTimes(2);
+ });
+
+ it('includes include_permissions=true in every poll call', () => {
+ setupGetMock(runningData);
+ const store = makeStore();
+
+ store.dispatch(getJobInvocation(url, ref));
+ jest.advanceTimersByTime(5000);
+
+ expect(api.get).toHaveBeenCalledTimes(2);
+ expect(api.get.mock.calls[1][0].params).toMatchObject({
+ include_hosts: false,
+ include_permissions: true,
+ });
+ });
+
+ it('stops polling when job succeeds', () => {
+ setupGetMock(succeededData);
+ const store = makeStore();
+
+ store.dispatch(getJobInvocation(url, ref));
+ expect(api.get).toHaveBeenCalledTimes(1);
+
+ jest.advanceTimersByTime(5000);
+ expect(api.get).toHaveBeenCalledTimes(1);
+ });
+
+ it('stops polling when job fails', () => {
+ setupGetMock(failedData);
+ const store = makeStore();
+
+ store.dispatch(getJobInvocation(url, ref));
+ jest.advanceTimersByTime(5000);
+
+ expect(api.get).toHaveBeenCalledTimes(1);
+ });
+
+ it('stops polling when job is cancelled', () => {
+ setupGetMock(cancelledData);
+ const store = makeStore();
+
+ store.dispatch(getJobInvocation(url, ref));
+ jest.advanceTimersByTime(5000);
+
+ expect(api.get).toHaveBeenCalledTimes(1);
+ });
+
+ it('stops polling on fetch error', () => {
+ api.get.mockImplementation(({ handleError, ...action }) => {
+ handleError && handleError();
+ return { type: 'get', ...action };
+ });
+ const store = makeStore();
+
+ store.dispatch(getJobInvocation(url, ref));
+ jest.advanceTimersByTime(5000);
+
+ expect(api.get).toHaveBeenCalledTimes(1);
+ });
+
+ it('stopJobInvocationPolling cancels a pending timeout', () => {
+ setupGetMock(runningData);
+ const store = makeStore();
+
+ store.dispatch(getJobInvocation(url, ref));
+ expect(api.get).toHaveBeenCalledTimes(1);
+
+ stopJobInvocationPolling(ref);
+ jest.advanceTimersByTime(5000);
+
+ expect(api.get).toHaveBeenCalledTimes(1);
+ });
+
+ it('getJobInvocation cancels any existing poll before starting a new one', () => {
+ setupGetMock(runningData);
+ const store = makeStore();
+
+ store.dispatch(getJobInvocation(url, ref));
+ expect(api.get).toHaveBeenCalledTimes(1);
+
+ // Second call before the timeout fires should cancel the pending poll
+ store.dispatch(getJobInvocation(url, ref));
+ jest.advanceTimersByTime(5000);
+
+ // Only one more poll should fire (from the second invocation), not two
+ expect(api.get).toHaveBeenCalledTimes(3);
+ });
+
+ it('keeps polling as long as the job is running', () => {
+ setupGetMock(runningData);
+ const store = makeStore();
+
+ store.dispatch(getJobInvocation(url, ref));
+ jest.advanceTimersByTime(15000);
+
+ expect(api.get).toHaveBeenCalledTimes(4);
+ });
+});
+
+describe('enableRecurringLogic', () => {
+ const mockStore = configureMockStore([thunk]);
+
+ it('re-fetches job invocation after successful disable/enable', () => {
+ jest
+ .spyOn(api.APIActions, 'put')
+ .mockImplementation(({ handleSuccess, ...action }) => {
+ handleSuccess && handleSuccess();
+ return { type: 'MOCK_PUT', ...action };
+ });
+ api.get.mockImplementation(({ ...action }) => ({ type: 'get', ...action }));
+
+ const store = mockStore({});
+ store.dispatch(enableRecurringLogic(1, true, 42));
+
+ const actions = store.getActions();
+ const getAction = actions.find(a => a.key === JOB_INVOCATION_KEY);
+ expect(getAction).toBeDefined();
+ expect(getAction.url).toBe('/api/job_invocations/42');
+
+ api.APIActions.put.mockRestore();
+ api.get.mockReset();
+ });
+});
diff --git a/webpack/JobInvocationDetail/__tests__/TemplateInvocationPolling.test.js b/webpack/JobInvocationDetail/__tests__/TemplateInvocationPolling.test.js
new file mode 100644
index 000000000..4521e2688
--- /dev/null
+++ b/webpack/JobInvocationDetail/__tests__/TemplateInvocationPolling.test.js
@@ -0,0 +1,254 @@
+import React from 'react';
+import { createStore, applyMiddleware } from 'redux';
+import thunk from 'redux-thunk';
+import { Provider } from 'react-redux';
+import { render, act } from '@testing-library/react';
+import '@testing-library/jest-dom/extend-expect';
+import * as api from 'foremanReact/redux/API';
+import * as selectors from '../JobInvocationSelectors';
+import { TemplateInvocation } from '../TemplateInvocation';
+import { mockTemplateInvocationResponse } from './fixtures';
+
+jest.spyOn(api, 'get');
+jest.mock('../JobInvocationSelectors');
+
+jest.mock('foremanReact/components/ToastsList', () => ({
+ addToast: jest.fn(payload => ({ type: 'ADD_TOAST', payload })),
+}));
+
+describe('TemplateInvocation polling', () => {
+ const noop = () => {};
+ const reducer = (state = {}) => state;
+ const makeStore = () => createStore(reducer, applyMiddleware(thunk));
+
+ const pollingProps = {
+ hostID: '1',
+ jobID: '1',
+ isInTableView: false,
+ isExpanded: true,
+ hostName: 'example-host',
+ hostProxy: { name: 'example-proxy', href: '#' },
+ showOutputType: { stderr: true, stdout: true, debug: true },
+ setShowOutputType: noop,
+ showTemplatePreview: false,
+ setShowTemplatePreview: noop,
+ showCommand: false,
+ setShowCommand: noop,
+ };
+
+ let apiGetSpy;
+
+ beforeEach(() => {
+ jest.useFakeTimers({ legacyFakeTimers: true });
+ selectors.selectTemplateInvocationStatus.mockImplementation(() => () =>
+ 'RESOLVED'
+ );
+ selectors.selectTemplateInvocation.mockImplementation(() => () =>
+ mockTemplateInvocationResponse
+ );
+ apiGetSpy = jest
+ .spyOn(api.APIActions, 'get')
+ .mockImplementation(({ handleSuccess }) => {
+ handleSuccess &&
+ handleSuccess({ data: { finished: false, auto_refresh: true } });
+ return { type: 'MOCK_GET' };
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ apiGetSpy.mockRestore();
+ });
+
+ it('fetches on mount when isExpanded is true', () => {
+ const localStore = makeStore();
+ render(
+
+
+
+ );
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not fetch on mount when isExpanded is false', () => {
+ const localStore = makeStore();
+ render(
+
+
+
+ );
+ expect(apiGetSpy).not.toHaveBeenCalled();
+ });
+
+ it('schedules next poll via setTimeout when auto_refresh is true and not finished', () => {
+ const localStore = makeStore();
+ render(
+
+
+
+ );
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+
+ act(() => jest.advanceTimersByTime(5000));
+ expect(apiGetSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not schedule next poll when finished is true', () => {
+ apiGetSpy.mockImplementation(({ handleSuccess }) => {
+ handleSuccess &&
+ handleSuccess({ data: { finished: true, auto_refresh: true } });
+ return { type: 'MOCK_GET' };
+ });
+ const localStore = makeStore();
+ render(
+
+
+
+ );
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+
+ act(() => jest.advanceTimersByTime(5000));
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not schedule next poll when auto_refresh is false', () => {
+ apiGetSpy.mockImplementation(({ handleSuccess }) => {
+ handleSuccess &&
+ handleSuccess({ data: { finished: false, auto_refresh: false } });
+ return { type: 'MOCK_GET' };
+ });
+ const localStore = makeStore();
+ render(
+
+
+
+ );
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+
+ act(() => jest.advanceTimersByTime(5000));
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('clears the polling timeout and sets cancelled on unmount', () => {
+ const localStore = makeStore();
+ const { unmount } = render(
+
+
+
+ );
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+
+ unmount();
+ act(() => jest.advanceTimersByTime(5000));
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('cancels in-flight callback when unmounted before handleSuccess runs', () => {
+ let capturedHandleSuccess;
+ apiGetSpy.mockImplementation(({ handleSuccess }) => {
+ capturedHandleSuccess = handleSuccess;
+ return { type: 'MOCK_GET' };
+ });
+
+ const localStore = makeStore();
+ const { unmount } = render(
+
+
+
+ );
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+
+ unmount();
+
+ act(() => {
+ capturedHandleSuccess({ data: { finished: false, auto_refresh: true } });
+ jest.advanceTimersByTime(5000);
+ });
+
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('re-fetches when isExpanded changes from false to true', () => {
+ const localStore = makeStore();
+ const { rerender } = render(
+
+
+
+ );
+ expect(apiGetSpy).not.toHaveBeenCalled();
+
+ rerender(
+
+
+
+ );
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not re-fetch on expand when response is already finished', () => {
+ selectors.selectTemplateInvocation.mockImplementation(() => () => ({
+ ...mockTemplateInvocationResponse,
+ finished: true,
+ }));
+ apiGetSpy.mockImplementation(({ handleSuccess }) => {
+ handleSuccess &&
+ handleSuccess({ data: { finished: true, auto_refresh: false } });
+ return { type: 'MOCK_GET' };
+ });
+
+ const localStore = makeStore();
+ const { rerender } = render(
+
+
+
+ );
+ expect(apiGetSpy).not.toHaveBeenCalled();
+
+ rerender(
+
+
+
+ );
+ // Selector already returns finished=true → guard skips dispatchFetch
+ expect(apiGetSpy).not.toHaveBeenCalled();
+
+ rerender(
+
+
+
+ );
+ rerender(
+
+
+
+ );
+ // Second expand: still finished → still no fetch
+ expect(apiGetSpy).not.toHaveBeenCalled();
+ });
+
+ it('cancels existing poll and starts fresh when isExpanded changes', () => {
+ const localStore = makeStore();
+ const { rerender } = render(
+
+
+
+ );
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+
+ rerender(
+
+
+
+ );
+ act(() => jest.advanceTimersByTime(5000));
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
+
+ rerender(
+
+
+
+ );
+ expect(apiGetSpy).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/webpack/JobInvocationDetail/index.js b/webpack/JobInvocationDetail/index.js
index 085aee8f9..7125bf209 100644
--- a/webpack/JobInvocationDetail/index.js
+++ b/webpack/JobInvocationDetail/index.js
@@ -5,19 +5,19 @@ import {
PageSectionVariants,
Skeleton,
} from '@patternfly/react-core';
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
import { translate as __, documentLocale } from 'foremanReact/common/I18n';
import { useDispatch, useSelector } from 'react-redux';
import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout';
import PropTypes from 'prop-types';
import SkeletonLoader from 'foremanReact/components/common/SkeletonLoader';
-import { stopInterval } from 'foremanReact/redux/middlewares/IntervalMiddleware';
import { STATUS as API_STATUS } from 'foremanReact/constants';
import {
selectAPIErrorMessage,
selectAPIHttpStatus,
selectAPIStatus,
} from 'foremanReact/redux/API/APISelectors';
+import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
import { JobAdditionInfo } from './JobAdditionInfo';
import JobInvocationHostTable from './JobInvocationHostTable';
@@ -25,13 +25,18 @@ import JobInvocationOverview from './JobInvocationOverview';
import JobInvocationSystemStatusChart from './JobInvocationSystemStatusChart';
import JobInvocationEmptyState from './JobInvocationEmptyState';
import JobInvocationToolbarButtons from './JobInvocationToolbarButtons';
-import { getJobInvocation, getTask } from './JobInvocationActions';
+import {
+ getJobInvocation,
+ isJobFinished,
+ stopJobInvocationPolling,
+} from './JobInvocationActions';
import './JobInvocationDetail.scss';
import {
+ CURRENT_PERMISSIONS,
DATE_OPTIONS,
JOB_INVOCATION_KEY,
- STATUS,
STATUS_UPPERCASE,
+ currentPermissionsUrl,
} from './JobInvocationConstants';
import { selectItems } from './JobInvocationSelectors';
@@ -42,6 +47,7 @@ const JobInvocationDetailPage = ({
history,
}) => {
const dispatch = useDispatch();
+ const pollTimeoutRef = useRef(null);
const items = useSelector(selectItems);
const {
description,
@@ -50,11 +56,6 @@ const JobInvocationDetailPage = ({
start_at: startAt,
targeting = {},
} = items;
- const finished =
- statusLabel === STATUS.FAILED ||
- statusLabel === STATUS.SUCCEEDED ||
- statusLabel === STATUS.CANCELLED;
- const autoRefresh = task?.state === STATUS.PENDING || false;
const jobInvocationApiStatus = useSelector(state =>
selectAPIStatus(state, JOB_INVOCATION_KEY)
);
@@ -64,6 +65,9 @@ const JobInvocationDetailPage = ({
const jobInvocationHttpStatus = useSelector(state =>
selectAPIHttpStatus(state, JOB_INVOCATION_KEY)
);
+ useAPI('get', currentPermissionsUrl, {
+ key: CURRENT_PERMISSIONS,
+ });
const [selectedFilter, setSelectedFilter] = useState('');
const handleFilterChange = newFilter => {
@@ -82,21 +86,11 @@ const JobInvocationDetailPage = ({
}
useEffect(() => {
- dispatch(getJobInvocation(`/api/job_invocations/${id}`));
- if (finished && !autoRefresh) {
- dispatch(stopInterval(JOB_INVOCATION_KEY));
- }
+ dispatch(getJobInvocation(`/api/job_invocations/${id}`, pollTimeoutRef));
return () => {
- dispatch(stopInterval(JOB_INVOCATION_KEY));
+ stopJobInvocationPolling(pollTimeoutRef);
};
- }, [dispatch, id, finished, autoRefresh]);
-
- const taskId = task?.id;
- useEffect(() => {
- if (taskId !== undefined) {
- dispatch(getTask(`${taskId}`));
- }
- }, [dispatch, taskId]);
+ }, [dispatch, id]);
const apiFailed = jobInvocationApiStatus === API_STATUS.ERROR;
@@ -216,10 +210,8 @@ const JobInvocationDetailPage = ({