From a98c8568221e46981028ff511d930b6d22bd7b8b Mon Sep 17 00:00:00 2001 From: kmalyjur Date: Wed, 29 May 2024 15:11:32 +0000 Subject: [PATCH] Fixes #37286 - Add global actions to job invocation detail page --- .../api/v2/job_invocations_controller.rb | 1 + app/models/job_invocation.rb | 23 ++ app/models/job_invocation_composer.rb | 2 + .../api/v2/job_invocations/base.json.rabl | 5 +- package.json | 3 +- .../JobInvocationActions.js | 137 ++++++++- .../JobInvocationConstants.js | 14 + .../JobInvocationDetail.scss | 7 +- .../JobInvocationSelectors.js | 5 + .../JobInvocationSystemStatusChart.js | 22 +- .../JobInvocationToolbarButtons.js | 268 ++++++++++++++++++ .../__tests__/MainInformation.test.js | 259 +++++++++++++++++ .../JobInvocationDetail/__tests__/fixtures.js | 117 ++++++++ webpack/JobInvocationDetail/index.js | 96 ++++--- webpack/JobWizard/JobWizardPageRerun.js | 25 +- .../components/BreadcrumbBar/index.js | 4 + .../foremanReact/components/Head/index.js | 10 + .../DetailsCard/DefaultLoaderEmptyState.js | 8 + .../components/ToastsList/index.js | 3 + .../foremanReact/redux/API/APIActions.js | 21 ++ .../foremanReact/redux/API/APIConstants.js | 7 + .../__mocks__/foremanReact/redux/API/index.js | 14 + .../middlewares/IntervalMiddleware/index.js | 9 + 23 files changed, 995 insertions(+), 65 deletions(-) create mode 100644 webpack/JobInvocationDetail/JobInvocationToolbarButtons.js create mode 100644 webpack/JobInvocationDetail/__tests__/MainInformation.test.js create mode 100644 webpack/JobInvocationDetail/__tests__/fixtures.js create mode 100644 webpack/__mocks__/foremanReact/components/BreadcrumbBar/index.js create mode 100644 webpack/__mocks__/foremanReact/components/Head/index.js create mode 100644 webpack/__mocks__/foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState.js create mode 100644 webpack/__mocks__/foremanReact/components/ToastsList/index.js create mode 100644 webpack/__mocks__/foremanReact/redux/API/APIActions.js create mode 100644 webpack/__mocks__/foremanReact/redux/API/APIConstants.js create mode 100644 webpack/__mocks__/foremanReact/redux/middlewares/IntervalMiddleware/index.js diff --git a/app/controllers/api/v2/job_invocations_controller.rb b/app/controllers/api/v2/job_invocations_controller.rb index c0f26633e..a27b9464b 100644 --- a/app/controllers/api/v2/job_invocations_controller.rb +++ b/app/controllers/api/v2/job_invocations_controller.rb @@ -139,6 +139,7 @@ def cancel api :POST, '/job_invocations/:id/rerun', N_('Rerun job on failed hosts') param :id, :identifier, :required => true param :failed_only, :bool + param :succeeded_only, :bool def rerun composer = JobInvocationComposer.from_job_invocation(@job_invocation, params) if composer.rerun_possible? diff --git a/app/models/job_invocation.rb b/app/models/job_invocation.rb index 03690e05a..ced3b79d8 100644 --- a/app/models/job_invocation.rb +++ b/app/models/job_invocation.rb @@ -194,6 +194,10 @@ def failed_host_ids failed_hosts.pluck(:id) end + def succeeded_host_ids + succeeded_hosts.pluck(:id) + end + def failed_hosts base = targeting.hosts if finished? @@ -203,6 +207,15 @@ def failed_hosts end end + def succeeded_hosts + base = targeting.hosts + if finished? + base.where.not(:id => not_succeeded_template_invocations.select(:host_id)) + else + base.where(:id => succeeded_template_invocations.select(:host_id)) + end + end + def total_hosts_count count = _('N/A') @@ -290,4 +303,14 @@ def not_failed_template_invocations results = [:cancelled, :failed].map { |state| TemplateInvocation::TaskResultMap.status_to_task_result(state) }.flatten template_invocations.joins(:run_host_job_task).where.not(ForemanTasks::Task.table_name => { :result => results }) end + + def succeeded_template_invocations + result = TemplateInvocation::TaskResultMap.status_to_task_result(:success) + template_invocations.joins(:run_host_job_task).where(ForemanTasks::Task.table_name => { :result => result }) + end + + def not_succeeded_template_invocations + result = TemplateInvocation::TaskResultMap.status_to_task_result(:success) + template_invocations.joins(:run_host_job_task).where.not(ForemanTasks::Task.table_name => { :result => result }) + end end diff --git a/app/models/job_invocation_composer.rb b/app/models/job_invocation_composer.rb index 99c2069a9..ed661d6c0 100644 --- a/app/models/job_invocation_composer.rb +++ b/app/models/job_invocation_composer.rb @@ -232,6 +232,8 @@ def initialize(job_invocation, params = {}) @host_ids = params[:host_ids] elsif params[:failed_only] @host_ids = job_invocation.failed_host_ids + elsif params[:succeeded_only] + @host_ids = job_invocation.succeeded_host_ids end end diff --git a/app/views/api/v2/job_invocations/base.json.rabl b/app/views/api/v2/job_invocations/base.json.rabl index 5e8c21666..b01c5f059 100644 --- a/app/views/api/v2/job_invocations/base.json.rabl +++ b/app/views/api/v2/job_invocations/base.json.rabl @@ -25,9 +25,10 @@ end if params.key?(:include_permissions) node :permissions do |invocation| authorizer = Authorizer.new(User.current) - edit_job_templates_permission = Permission.where(name: "edit_job_templates", resource_type: "JobTemplate").first { - "edit_job_templates" => (edit_job_templates_permission && authorizer.can?("edit_job_templates", invocation, false)), + "edit_job_templates" => authorizer.can?("edit_job_templates", invocation, false), + "view_foreman_tasks" => authorizer.can?("view_foreman_tasks", invocation.task, false), + "edit_recurring_logics" => authorizer.can?("edit_recurring_logics", invocation.recurring_logic, false), } end end diff --git a/package.json b/package.json index dad1a0ab4..e0e0dcf6c 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "prettier": "^1.19.1", "redux-mock-store": "^1.2.2", "graphql-tag": "^2.11.0", - "graphql": "^15.5.0" + "graphql": "^15.5.0", + "victory-core": "~36.8.6" }, "peerDependencies": { "@theforeman/vendor": ">= 12.0.1" diff --git a/webpack/JobInvocationDetail/JobInvocationActions.js b/webpack/JobInvocationDetail/JobInvocationActions.js index 6d0949c7c..a70b80e6b 100644 --- a/webpack/JobInvocationDetail/JobInvocationActions.js +++ b/webpack/JobInvocationDetail/JobInvocationActions.js @@ -1,9 +1,19 @@ -import { get } from 'foremanReact/redux/API'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; +import { foremanUrl } from 'foremanReact/common/helpers'; +import { addToast } from 'foremanReact/components/ToastsList'; +import { APIActions, get } from 'foremanReact/redux/API'; import { - withInterval, stopInterval, + withInterval, } from 'foremanReact/redux/middlewares/IntervalMiddleware'; -import { JOB_INVOCATION_KEY } from './JobInvocationConstants'; +import { + CANCEL_JOB, + CANCEL_RECURRING_LOGIC, + CHANGE_ENABLED_RECURRING_LOGIC, + GET_TASK, + JOB_INVOCATION_KEY, + UPDATE_JOB, +} from './JobInvocationConstants'; export const getData = url => dispatch => { const fetchData = withInterval( @@ -20,3 +30,124 @@ export const getData = url => dispatch => { dispatch(fetchData); }; + +export const updateJob = jobId => dispatch => { + const url = foremanUrl(`/api/job_invocations/${jobId}`); + dispatch( + APIActions.get({ + url, + key: UPDATE_JOB, + }) + ); +}; + +export const cancelJob = (jobId, force) => dispatch => { + const infoToast = () => + force + ? sprintf(__('Trying to abort the job %s.'), jobId) + : sprintf(__('Trying to cancel the job %s.'), jobId); + const errorToast = response => + force + ? sprintf(__(`Could not abort the job %s: ${response}`), jobId) + : sprintf(__(`Could not cancel the job %s: ${response}`), jobId); + const url = force + ? `/job_invocations/${jobId}/cancel?force=true` + : `/job_invocations/${jobId}/cancel`; + + dispatch( + APIActions.post({ + url, + key: CANCEL_JOB, + errorToast: ({ response }) => + errorToast( + // eslint-disable-next-line camelcase + response?.data?.error?.full_messages || + response?.data?.error?.message || + 'Unknown error.' + ), + handleSuccess: () => { + dispatch( + addToast({ + key: `cancel-job-error`, + type: 'info', + message: infoToast(), + }) + ); + dispatch(updateJob(jobId)); + }, + }) + ); +}; + +export const getTask = taskId => dispatch => { + dispatch( + get({ + key: GET_TASK, + url: `/foreman_tasks/api/tasks/${taskId}`, + }) + ); +}; + +export const enableRecurringLogic = ( + recurrenceId, + enabled, + jobId +) => dispatch => { + const successToast = () => + enabled + ? sprintf(__('Recurring logic %s disabled successfully.'), recurrenceId) + : sprintf(__('Recurring logic %s enabled successfully.'), recurrenceId); + const errorToast = response => + enabled + ? sprintf( + __(`Could not disable recurring logic %s: ${response}`), + recurrenceId + ) + : sprintf( + __(`Could not enable recurring logic %s: ${response}`), + recurrenceId + ); + const url = `/foreman_tasks/api/recurring_logics/${recurrenceId}`; + dispatch( + APIActions.put({ + url, + key: CHANGE_ENABLED_RECURRING_LOGIC, + params: { recurring_logic: { enabled: !enabled } }, + successToast, + errorToast: ({ response }) => + errorToast( + // eslint-disable-next-line camelcase + response?.data?.error?.full_messages || + response?.data?.error?.message || + 'Unknown error.' + ), + handleSuccess: () => dispatch(updateJob(jobId)), + }) + ); +}; + +export const cancelRecurringLogic = (recurrenceId, jobId) => dispatch => { + const successToast = () => + sprintf(__('Recurring logic %s cancelled successfully.'), recurrenceId); + const errorToast = response => + sprintf( + __(`Could not cancel recurring logic %s: ${response}`), + recurrenceId + ); + const url = `/foreman_tasks/recurring_logics/${recurrenceId}/cancel`; + dispatch( + APIActions.post({ + url, + key: CANCEL_RECURRING_LOGIC, + successToast, + errorToast: ({ response }) => + errorToast( + // eslint-disable-next-line camelcase + response?.data?.error?.full_messages || + response?.data?.error?.message || + 'Unknown error.' + ), + handleSuccess: () => dispatch(updateJob(jobId)), + }) + ); +}; diff --git a/webpack/JobInvocationDetail/JobInvocationConstants.js b/webpack/JobInvocationDetail/JobInvocationConstants.js index 7bec1ac8d..2673d3aaf 100644 --- a/webpack/JobInvocationDetail/JobInvocationConstants.js +++ b/webpack/JobInvocationDetail/JobInvocationConstants.js @@ -1,9 +1,23 @@ +import { foremanUrl } from 'foremanReact/common/helpers'; + export const JOB_INVOCATION_KEY = 'JOB_INVOCATION_KEY'; +export const CURRENT_PERMISSIONS = 'CURRENT_PERMISSIONS'; +export const UPDATE_JOB = 'UPDATE_JOB'; +export const CANCEL_JOB = 'CANCEL_JOB'; +export const GET_TASK = 'GET_TASK'; +export const CHANGE_ENABLED_RECURRING_LOGIC = 'CHANGE_ENABLED_RECURRING_LOGIC'; +export const CANCEL_RECURRING_LOGIC = 'CANCEL_RECURRING_LOGIC'; +export const GET_REPORT_TEMPLATES = 'GET_REPORT_TEMPLATES'; +export const GET_REPORT_TEMPLATE_INPUTS = 'GET_REPORT_TEMPLATE_INPUTS'; +export const currentPermissionsUrl = foremanUrl( + '/api/v2/permissions/current_permissions' +); export const STATUS = { PENDING: 'pending', SUCCEEDED: 'succeeded', FAILED: 'failed', + CANCELLED: 'cancelled', }; export const DATE_OPTIONS = { diff --git a/webpack/JobInvocationDetail/JobInvocationDetail.scss b/webpack/JobInvocationDetail/JobInvocationDetail.scss index 4703706dd..9482277a0 100644 --- a/webpack/JobInvocationDetail/JobInvocationDetail.scss +++ b/webpack/JobInvocationDetail/JobInvocationDetail.scss @@ -1,6 +1,8 @@ -.job-invocation-detail-page-section { +.job-invocation-detail-flex { $chart_size: 105px; - + padding-top: 0px; + padding-left: 10px; + .chart-donut { height: $chart_size; width: $chart_size; @@ -36,3 +38,4 @@ height: $chart_size; } } + \ No newline at end of file diff --git a/webpack/JobInvocationDetail/JobInvocationSelectors.js b/webpack/JobInvocationDetail/JobInvocationSelectors.js index 6c0cf4d68..cd2eb57ca 100644 --- a/webpack/JobInvocationDetail/JobInvocationSelectors.js +++ b/webpack/JobInvocationDetail/JobInvocationSelectors.js @@ -3,3 +3,8 @@ import { JOB_INVOCATION_KEY } 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; diff --git a/webpack/JobInvocationDetail/JobInvocationSystemStatusChart.js b/webpack/JobInvocationDetail/JobInvocationSystemStatusChart.js index f5813b088..0497fc5a5 100644 --- a/webpack/JobInvocationDetail/JobInvocationSystemStatusChart.js +++ b/webpack/JobInvocationDetail/JobInvocationSystemStatusChart.js @@ -1,6 +1,7 @@ -import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; +import React, { useEffect, useState } from 'react'; import { translate as __, sprintf } from 'foremanReact/common/I18n'; +import DefaultLoaderEmptyState from 'foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState'; import { ChartDonut, ChartLabel, @@ -9,20 +10,19 @@ import { } from '@patternfly/react-charts'; import { DescriptionList, - DescriptionListTerm, - DescriptionListGroup, DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, FlexItem, Text, } from '@patternfly/react-core'; import { - global_palette_green_500 as successedColor, - global_palette_red_100 as failedColor, - global_palette_blue_300 as inProgressColor, global_palette_black_600 as canceledColor, global_palette_black_500 as emptyChartDonut, + global_palette_red_100 as failedColor, + global_palette_blue_300 as inProgressColor, + global_palette_green_500 as successedColor, } from '@patternfly/react-tokens'; -import DefaultLoaderEmptyState from 'foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState'; import './JobInvocationDetail.scss'; const JobInvocationSystemStatusChart = ({ @@ -35,9 +35,9 @@ const JobInvocationSystemStatusChart = ({ failed, pending, cancelled, - total, total_hosts: totalHosts, // includes scheduled } = data; + const total = succeeded + failed + pending + cancelled; const chartData = [ { title: __('Succeeded:'), count: succeeded, color: successedColor.value }, { title: __('Failed:'), count: failed, color: failedColor.value }, @@ -82,7 +82,11 @@ const JobInvocationSystemStatusChart = ({ total > 0 ? chartData.map(d => d.color) : [emptyChartDonut.value] } labelComponent={ - + } title={chartDonutTitle} titleComponent={ diff --git a/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js b/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js new file mode 100644 index 000000000..02e838c70 --- /dev/null +++ b/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js @@ -0,0 +1,268 @@ +import PropTypes from 'prop-types'; +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + Button, + Dropdown, + DropdownItem, + DropdownPosition, + DropdownSeparator, + DropdownToggle, + Split, + SplitItem, +} from '@patternfly/react-core'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { foremanUrl } from 'foremanReact/common/helpers'; +import { STATUS as APIStatus } from 'foremanReact/constants'; +import { get } from 'foremanReact/redux/API'; +import { + cancelJob, + cancelRecurringLogic, + enableRecurringLogic, +} from './JobInvocationActions'; +import { + STATUS, + GET_REPORT_TEMPLATES, + GET_REPORT_TEMPLATE_INPUTS, +} from './JobInvocationConstants'; +import { selectTaskCancelable } from './JobInvocationSelectors'; + +const JobInvocationToolbarButtons = ({ + jobId, + data, + currentPermissions, + permissionsStatus, +}) => { + const { succeeded, failed, task, recurrence, permissions } = data; + const recurringEnabled = recurrence?.state === 'active'; + const canViewForemanTasks = permissions + ? permissions.view_foreman_tasks + : false; + const canEditRecurringLogic = permissions + ? permissions.edit_recurring_logics + : false; + const isTaskCancelable = useSelector(selectTaskCancelable); + const [isActionOpen, setIsActionOpen] = useState(false); + const [reportTemplateJobId, setReportTemplateJobId] = useState(undefined); + const [templateInputId, setTemplateInputId] = useState(undefined); + const queryParams = new URLSearchParams({ + [`report_template_report[input_values][${templateInputId}][value]`]: jobId, + }); + const dispatch = useDispatch(); + + const onActionFocus = () => { + const element = document.getElementById( + `toggle-split-button-action-primary-${jobId}` + ); + element.focus(); + }; + const onActionSelect = () => { + setIsActionOpen(false); + onActionFocus(); + }; + const hasPermission = permissionRequired => + permissionsStatus === APIStatus.RESOLVED + ? currentPermissions?.some( + permission => permission.name === permissionRequired + ) + : false; + + useEffect(() => { + dispatch( + get({ + key: GET_REPORT_TEMPLATES, + url: '/api/report_templates', + handleSuccess: ({ data: { results } }) => { + setReportTemplateJobId( + results.find(result => result.name === 'Job - Invocation Report') + ?.id + ); + }, + handleError: () => { + setReportTemplateJobId(undefined); + }, + }) + ); + }, [dispatch]); + useEffect(() => { + if (reportTemplateJobId !== undefined) { + dispatch( + get({ + key: GET_REPORT_TEMPLATE_INPUTS, + url: `/api/templates/${reportTemplateJobId}/template_inputs`, + handleSuccess: ({ data: { results } }) => { + setTemplateInputId( + results.find(result => result.name === 'job_id')?.id + ); + }, + handleError: () => { + setTemplateInputId(undefined); + }, + }) + ); + } + }, [dispatch, reportTemplateJobId]); + + const recurrenceDropdownItems = recurrence + ? [ + , + + dispatch( + enableRecurringLogic(recurrence?.id, recurringEnabled, jobId) + ) + } + key="change-enabled-recurring" + component="button" + isDisabled={ + recurrence?.id === undefined || + recurrence?.state === 'cancelled' || + !canEditRecurringLogic + } + > + {recurringEnabled ? __('Disable recurring') : __('Enable recurring')} + , + dispatch(cancelRecurringLogic(recurrence?.id, jobId))} + key="cancel-recurring" + component="button" + isDisabled={ + recurrence?.id === undefined || + recurrence?.state === 'cancelled' || + !canEditRecurringLogic + } + > + {__('Cancel recurring')} + , + ] + : []; + + const dropdownItems = [ + 0) || !hasPermission('create_job_invocations')} + description="Rerun job on successful hosts" + > + {__('Rerun successful')} + , + 0) || !hasPermission('create_job_invocations')} + description="Rerun job on failed hosts" + > + {__('Rerun failed')} + , + + {__('View task')} + , + , + dispatch(cancelJob(jobId, false))} + key="cancel" + component="button" + isDisabled={!isTaskCancelable || !hasPermission('cancel_job_invocations')} + description="Cancel job gracefully" + > + {__('Cancel')} + , + dispatch(cancelJob(jobId, true))} + key="abort" + component="button" + isDisabled={!isTaskCancelable || !hasPermission('cancel_job_invocations')} + description="Cancel job immediately" + > + {__('Abort')} + , + ...recurrenceDropdownItems, + , + + {__('Legacy UI')} + , + ]; + + return ( + <> + + + + + + + {__(`Rerun all`)} + , + ]} + splitButtonVariant="action" + onToggle={setIsActionOpen} + /> + } + isOpen={isActionOpen} + dropdownItems={dropdownItems} + /> + + + + ); +}; + +JobInvocationToolbarButtons.propTypes = { + jobId: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + currentPermissions: PropTypes.array, + permissionsStatus: PropTypes.string, +}; +JobInvocationToolbarButtons.defaultProps = { + currentPermissions: undefined, + permissionsStatus: undefined, +}; + +export default JobInvocationToolbarButtons; diff --git a/webpack/JobInvocationDetail/__tests__/MainInformation.test.js b/webpack/JobInvocationDetail/__tests__/MainInformation.test.js new file mode 100644 index 000000000..83d6e045c --- /dev/null +++ b/webpack/JobInvocationDetail/__tests__/MainInformation.test.js @@ -0,0 +1,259 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { fireEvent, render, screen, act } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { Provider } from 'react-redux'; +import thunk from 'redux-thunk'; +import { foremanUrl } from 'foremanReact/common/helpers'; +import * as api from 'foremanReact/redux/API'; +import JobInvocationDetailPage from '../index'; +import { + jobInvocationData, + jobInvocationDataScheduled, + jobInvocationDataRecurring, + mockPermissionsData, + mockReportTemplatesResponse, + mockReportTemplateInputsResponse, +} from './fixtures'; +import { + cancelJob, + enableRecurringLogic, + cancelRecurringLogic, +} from '../JobInvocationActions'; +import { + CANCEL_JOB, + CANCEL_RECURRING_LOGIC, + CHANGE_ENABLED_RECURRING_LOGIC, + GET_REPORT_TEMPLATES, + GET_REPORT_TEMPLATE_INPUTS, + JOB_INVOCATION_KEY, +} from '../JobInvocationConstants'; + +jest.spyOn(api, 'get'); + +jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({ + useAPI: jest.fn(() => ({ + response: mockPermissionsData, + })), +})); + +jest.mock('foremanReact/routes/common/PageLayout/PageLayout', () => + jest.fn(props => ( +
+ {props.header &&

{props.header}

} + {props.toolbarButtons &&
{props.toolbarButtons}
} + {props.children} +
+ )) +); + +const initialState = { + JOB_INVOCATION_KEY: { + response: jobInvocationData, + }, + GET_REPORT_TEMPLATES: mockReportTemplatesResponse, +}; + +const initialStateScheduled = { + JOB_INVOCATION_KEY: { + response: jobInvocationDataScheduled, + }, +}; + +api.get.mockImplementation(({ handleSuccess, ...action }) => { + if (action.key === 'GET_REPORT_TEMPLATES') { + handleSuccess && + handleSuccess({ + data: mockReportTemplatesResponse, + }); + } else if (action.key === 'GET_REPORT_TEMPLATE_INPUTS') { + handleSuccess && + handleSuccess({ + data: mockReportTemplateInputsResponse, + }); + } + + return { type: 'get', ...action }; +}); + +const reportTemplateJobId = mockReportTemplatesResponse.results[0].id; + +const mockStore = configureMockStore([thunk]); + +describe('JobInvocationDetailPage', () => { + it('renders main information', async () => { + const jobId = jobInvocationData.id; + const store = mockStore(initialState); + + const { container } = render( + + + + ); + + expect(screen.getByText('Description')).toBeInTheDocument(); + expect( + container.querySelector('.chart-donut .pf-c-chart') + ).toBeInTheDocument(); + expect(screen.getByText('2/6')).toBeInTheDocument(); + expect(screen.getByText('Systems')).toBeInTheDocument(); + expect(screen.getByText('System status')).toBeInTheDocument(); + expect(screen.getByText('Succeeded: 2')).toBeInTheDocument(); + expect(screen.getByText('Failed: 4')).toBeInTheDocument(); + expect(screen.getByText('In Progress: 0')).toBeInTheDocument(); + expect(screen.getByText('Canceled: 0')).toBeInTheDocument(); + + const informationToCheck = { + 'Effective user:': jobInvocationData.effective_user, + 'Started at:': 'Jan 1, 2024, 11:34 UTC', + 'SSH user:': 'Not available', + 'Template:': jobInvocationData.template_name, + }; + + Object.entries(informationToCheck).forEach(([term, expectedText]) => { + const termContainers = container.querySelectorAll( + '.pf-c-description-list__term .pf-c-description-list__text' + ); + termContainers.forEach(termContainer => { + if (termContainer.textContent.includes(term)) { + let descriptionContainer; + if (term === 'SSH user:') { + descriptionContainer = termContainer + .closest('.pf-c-description-list__group') + .querySelector( + '.pf-c-description-list__description .pf-c-description-list__text .disabled-text' + ); + } else { + descriptionContainer = termContainer + .closest('.pf-c-description-list__group') + .querySelector( + '.pf-c-description-list__description .pf-c-description-list__text' + ); + } + expect(descriptionContainer.textContent).toContain(expectedText); + } + }); + }); + + // checks the global actions and if they link to the correct url + expect(screen.getByText('Create report').getAttribute('href')).toEqual( + foremanUrl( + `/templates/report_templates/${mockReportTemplatesResponse.results[0].id}/generate?report_template_report%5Binput_values%5D%5B${mockReportTemplateInputsResponse.results[0].id}%5D%5Bvalue%5D=${jobId}` + ) + ); + expect(screen.getByText('Rerun all').getAttribute('href')).toEqual( + foremanUrl(`/job_invocations/${jobId}/rerun`) + ); + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Select' })); + }); + expect( + screen + .getByText('Rerun successful') + .closest('a') + .getAttribute('href') + ).toEqual(foremanUrl(`/job_invocations/${jobId}/rerun?succeeded_only=1`)); + expect( + screen + .getByText('Rerun failed') + .closest('a') + .getAttribute('href') + ).toEqual(foremanUrl(`/job_invocations/${jobId}/rerun?failed_only=1`)); + expect( + screen + .getByText('View task') + .closest('a') + .getAttribute('href') + ).toEqual(foremanUrl(`/foreman_tasks/tasks/${jobInvocationData.task.id}`)); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + expect(screen.getByText('Abort')).toBeInTheDocument(); + expect(screen.queryByText('Enable recurring')).not.toBeInTheDocument(); + expect(screen.queryByText('Cancel recurring')).not.toBeInTheDocument(); + expect( + screen + .getByText('Legacy UI') + .closest('a') + .getAttribute('href') + ).toEqual(`/job_invocations/${jobId}`); + }); + + it('shows scheduled date', async () => { + const store = mockStore(initialStateScheduled); + render( + + + + ); + + expect(screen.getByText('Scheduled at:')).toBeInTheDocument(); + expect(screen.getByText('Jan 1, 3000, 11:34 UTC')).toBeInTheDocument(); + expect(screen.getByText('Not yet')).toBeInTheDocument(); + }); + + it('should dispatch global actions', async () => { + // recurring in the future + const jobId = jobInvocationDataRecurring.id; + const recurrenceId = jobInvocationDataRecurring.recurrence.id; + const store = mockStore(jobInvocationDataRecurring); + render( + + + + ); + + const expectedActions = [ + { key: GET_REPORT_TEMPLATES, url: '/api/report_templates' }, + { + key: JOB_INVOCATION_KEY, + url: `/api/job_invocations/${jobId}`, + }, + { + key: GET_REPORT_TEMPLATE_INPUTS, + url: `/api/templates/${reportTemplateJobId}/template_inputs`, + }, + { + key: CANCEL_JOB, + url: `/job_invocations/${jobId}/cancel`, + }, + { + key: CANCEL_JOB, + url: `/job_invocations/${jobId}/cancel?force=true`, + }, + { + key: CHANGE_ENABLED_RECURRING_LOGIC, + url: `/foreman_tasks/api/recurring_logics/${recurrenceId}`, + }, + { + key: CHANGE_ENABLED_RECURRING_LOGIC, + url: `/foreman_tasks/api/recurring_logics/${recurrenceId}`, + }, + { + key: CANCEL_RECURRING_LOGIC, + url: `/foreman_tasks/recurring_logics/${recurrenceId}/cancel`, + }, + ]; + + store.dispatch(cancelJob(jobId, false)); + store.dispatch(cancelJob(jobId, true)); + store.dispatch(enableRecurringLogic(recurrenceId, true, jobId)); + store.dispatch(enableRecurringLogic(recurrenceId, false, jobId)); + store.dispatch(cancelRecurringLogic(recurrenceId, jobId)); + + const actualActions = store.getActions(); + expect(actualActions).toHaveLength(expectedActions.length); + + expectedActions.forEach((expectedAction, index) => { + if (actualActions[index].type === 'WITH_INTERVAL') { + expect(actualActions[index].key.key).toEqual(expectedAction.key); + expect(actualActions[index].key.url).toEqual(expectedAction.url); + } else { + expect(actualActions[index].key).toEqual(expectedAction.key); + if (expectedAction.url) { + expect(actualActions[index].url).toEqual(expectedAction.url); + } + } + }); + }); +}); diff --git a/webpack/JobInvocationDetail/__tests__/fixtures.js b/webpack/JobInvocationDetail/__tests__/fixtures.js new file mode 100644 index 000000000..c4f9e7846 --- /dev/null +++ b/webpack/JobInvocationDetail/__tests__/fixtures.js @@ -0,0 +1,117 @@ +export const jobInvocationData = { + id: 123, + description: 'Description', + job_category: 'Commands', + targeting_id: 123, + status: 1, + start_at: '2024-01-01 12:34:56 +0100', + status_label: 'failed', + ssh_user: null, + time_to_pickup: null, + template_id: 321, + template_name: 'Run Command - Script Default', + effective_user: 'root', + succeeded: 2, + failed: 4, + pending: 0, + cancelled: 0, + total: 6, + missing: 5, + total_hosts: 6, + task: { + id: '37ad5ead-51de-4798-bc73-a17687c4d5aa', + state: 'stopped', + }, + template_invocations: [ + { + template_id: 321, + template_name: 'Run Command - Script Default', + host_id: 1, + template_invocation_input_values: [ + { + template_input_name: 'command', + template_input_id: 59, + value: + 'echo start; for i in $(seq 1 120); do echo $i; sleep 1; done; echo done', + }, + ], + }, + ], +}; + +export const jobInvocationDataScheduled = { + id: 456, + description: 'Description', + job_category: 'Commands', + targeting_id: 456, + status: 1, + start_at: '3000-01-01 12:34:56 +0100', + status_label: 'failed', + ssh_user: null, + time_to_pickup: null, + template_id: 321, + template_name: 'Run Command - Script Default', + effective_user: 'root', + succeeded: 2, + failed: 4, + pending: 0, + cancelled: 0, + total: 6, + missing: 5, + total_hosts: 6, +}; + +export const jobInvocationDataRecurring = { + id: 789, + description: 'Description', + job_category: 'Commands', + targeting_id: 456, + status: 2, + start_at: '3000-01-01 12:00:00 +0100', + status_label: 'queued', + ssh_user: null, + time_to_pickup: null, + template_id: 321, + template_name: 'Run Command - Script Default', + effective_user: 'root', + succeeded: 0, + failed: 0, + pending: 0, + cancelled: 0, + total: 'N/A', + missing: 0, + total_hosts: 1, + task: { + id: '37ad5ead-51de-4798-bc73-a17687c4d5aa', + state: 'scheduled', + }, + mode: 'recurring', + recurrence: { + id: 1, + cron_line: '00 12 * * *', + end_time: null, + iteration: 1, + task_group_id: 12, + state: 'active', + max_iteration: null, + purpose: null, + task_count: 1, + action: 'Run hosts job:', + last_occurence: null, + next_occurence: '3000-01-01 12:00:00 +0100', + }, +}; + +export const mockPermissionsData = { + edit_job_templates: true, + view_foreman_tasks: true, + edit_recurring_logics: true, +}; + +export const mockReportTemplatesResponse = { + results: [{ id: '12', name: 'Job - Invocation Report' }], +}; + +export const mockReportTemplateInputsResponse = { + results: [{ id: '34', name: 'job_id' }], +}; diff --git a/webpack/JobInvocationDetail/index.js b/webpack/JobInvocationDetail/index.js index d07cfa4ca..1605a46ae 100644 --- a/webpack/JobInvocationDetail/index.js +++ b/webpack/JobInvocationDetail/index.js @@ -1,20 +1,24 @@ -import React, { useEffect } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; -import { Divider, PageSection, Flex } from '@patternfly/react-core'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Divider, Flex } from '@patternfly/react-core'; import { translate as __, documentLocale } from 'foremanReact/common/I18n'; import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout'; +import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; import { stopInterval } from 'foremanReact/redux/middlewares/IntervalMiddleware'; -import { getData } from './JobInvocationActions'; -import { selectItems } from './JobInvocationSelectors'; -import JobInvocationOverview from './JobInvocationOverview'; -import JobInvocationSystemStatusChart from './JobInvocationSystemStatusChart'; +import { getData, getTask } from './JobInvocationActions'; import { + CURRENT_PERMISSIONS, + DATE_OPTIONS, JOB_INVOCATION_KEY, STATUS, - DATE_OPTIONS, + currentPermissionsUrl, } from './JobInvocationConstants'; import './JobInvocationDetail.scss'; +import JobInvocationOverview from './JobInvocationOverview'; +import { selectItems } from './JobInvocationSelectors'; +import JobInvocationSystemStatusChart from './JobInvocationSystemStatusChart'; +import JobInvocationToolbarButtons from './JobInvocationToolbarButtons'; const JobInvocationDetailPage = ({ match: { @@ -30,8 +34,15 @@ const JobInvocationDetailPage = ({ start_at: startAt, } = items; const finished = - statusLabel === STATUS.FAILED || statusLabel === STATUS.SUCCEEDED; + statusLabel === STATUS.FAILED || + statusLabel === STATUS.SUCCEEDED || + statusLabel === STATUS.CANCELLED; const autoRefresh = task?.state === STATUS.PENDING || false; + const { response, status } = useAPI( + 'get', + currentPermissionsUrl, + CURRENT_PERMISSIONS + ); let isAlreadyStarted = false; let formattedStartDate; @@ -56,6 +67,12 @@ const JobInvocationDetailPage = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch, id, finished, autoRefresh]); + useEffect(() => { + if (task?.id !== undefined) { + dispatch(getTask(`${task?.id}`)); + } + }, [dispatch, task]); + const breadcrumbOptions = { breadcrumbItems: [ { caption: __('Jobs'), url: `/job_invocations` }, @@ -68,38 +85,41 @@ const JobInvocationDetailPage = ({ + } searchable={false} > - - + + + - - - - - - - - - + + + ); }; diff --git a/webpack/JobWizard/JobWizardPageRerun.js b/webpack/JobWizard/JobWizardPageRerun.js index a83054c64..280e586dc 100644 --- a/webpack/JobWizard/JobWizardPageRerun.js +++ b/webpack/JobWizard/JobWizardPageRerun.js @@ -1,15 +1,15 @@ -import React from 'react'; import PropTypes from 'prop-types'; +import React from 'react'; import URI from 'urijs'; -import { Alert, Divider, Skeleton, Button } from '@patternfly/react-core'; -import { sprintf, translate as __ } from 'foremanReact/common/I18n'; -import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; -import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout'; -import { STATUS } from 'foremanReact/constants'; +import { Alert, Button, Divider, Skeleton } from '@patternfly/react-core'; import { - useForemanOrganization, useForemanLocation, + useForemanOrganization, } from 'foremanReact/Root/Context/ForemanContext'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; +import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; +import { STATUS } from 'foremanReact/constants'; +import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout'; import { JobWizard } from './JobWizard'; import { JOB_API_KEY } from './JobWizardConstants'; @@ -21,11 +21,16 @@ const JobWizardPageRerun = ({ }) => { const uri = new URI(search); const { failed_only: failedOnly } = uri.search(true); + const { succeeded_only: succeededOnly } = uri.search(true); + let queryParams = ''; + if (failedOnly) { + queryParams = '&failed_only=1'; + } else if (succeededOnly) { + queryParams = '&succeeded_only=1'; + } const { response, status } = useAPI( 'get', - `/ui_job_wizard/job_invocation?id=${id}${ - failedOnly ? '&failed_only=1' : '' - }`, + `/ui_job_wizard/job_invocation?id=${id}${queryParams}`, JOB_API_KEY ); const title = __('Run job'); diff --git a/webpack/__mocks__/foremanReact/components/BreadcrumbBar/index.js b/webpack/__mocks__/foremanReact/components/BreadcrumbBar/index.js new file mode 100644 index 000000000..68159f0f3 --- /dev/null +++ b/webpack/__mocks__/foremanReact/components/BreadcrumbBar/index.js @@ -0,0 +1,4 @@ +import React from 'react'; + +const BreadcrumbBar = () =>
; +export default BreadcrumbBar; diff --git a/webpack/__mocks__/foremanReact/components/Head/index.js b/webpack/__mocks__/foremanReact/components/Head/index.js new file mode 100644 index 000000000..408e6ef30 --- /dev/null +++ b/webpack/__mocks__/foremanReact/components/Head/index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const Head = ({ children }) =>
{children}
; + +Head.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default Head; diff --git a/webpack/__mocks__/foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState.js b/webpack/__mocks__/foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState.js new file mode 100644 index 000000000..a8f9748d1 --- /dev/null +++ b/webpack/__mocks__/foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState.js @@ -0,0 +1,8 @@ +import React from 'react'; +import { translate as __ } from '../../../common/I18n'; + +const DefaultLoaderEmptyState = () => ( + {__('Not available')} +); + +export default DefaultLoaderEmptyState; diff --git a/webpack/__mocks__/foremanReact/components/ToastsList/index.js b/webpack/__mocks__/foremanReact/components/ToastsList/index.js new file mode 100644 index 000000000..a413bef00 --- /dev/null +++ b/webpack/__mocks__/foremanReact/components/ToastsList/index.js @@ -0,0 +1,3 @@ +import React from 'react'; + +export const ToastsList = () =>
; diff --git a/webpack/__mocks__/foremanReact/redux/API/APIActions.js b/webpack/__mocks__/foremanReact/redux/API/APIActions.js new file mode 100644 index 000000000..7247a99cc --- /dev/null +++ b/webpack/__mocks__/foremanReact/redux/API/APIActions.js @@ -0,0 +1,21 @@ +import { API_OPERATIONS } from 'foremanReact/redux/API/APIConstants'; + +const { GET, POST, PUT, DELETE, PATCH } = API_OPERATIONS; + +const apiAction = (type, payload) => ({ type, payload }); + +export const get = payload => apiAction(GET, payload); + +export const post = payload => apiAction(POST, payload); + +export const put = payload => apiAction(PUT, payload); + +export const patch = payload => apiAction(PATCH, payload); + +export const APIActions = { + get, + post, + put, + patch, + delete: payload => apiAction(DELETE, payload), +}; diff --git a/webpack/__mocks__/foremanReact/redux/API/APIConstants.js b/webpack/__mocks__/foremanReact/redux/API/APIConstants.js new file mode 100644 index 000000000..18f130639 --- /dev/null +++ b/webpack/__mocks__/foremanReact/redux/API/APIConstants.js @@ -0,0 +1,7 @@ +export const API_OPERATIONS = { + GET: 'API_GET', + POST: 'API_POST', + PUT: 'API_PUT', + DELETE: 'API_DELETE', + PATCH: 'API_PATCH', +}; diff --git a/webpack/__mocks__/foremanReact/redux/API/index.js b/webpack/__mocks__/foremanReact/redux/API/index.js index ede0cfb07..9f8313c72 100644 --- a/webpack/__mocks__/foremanReact/redux/API/index.js +++ b/webpack/__mocks__/foremanReact/redux/API/index.js @@ -1,5 +1,19 @@ export const API = { get: jest.fn(), + put: jest.fn(), + post: jest.fn(), + delete: jest.fn(), + patch: jest.fn(), }; export const get = data => ({ type: 'get-some-type', ...data }); +export const post = data => ({ type: 'post-some-type', ...data }); +export const put = data => ({ type: 'put-some-type', ...data }); +export const patch = data => ({ type: 'patch-some-type', ...data }); + +export const APIActions = { + get, + post, + put, + patch, +}; diff --git a/webpack/__mocks__/foremanReact/redux/middlewares/IntervalMiddleware/index.js b/webpack/__mocks__/foremanReact/redux/middlewares/IntervalMiddleware/index.js new file mode 100644 index 000000000..4560fe953 --- /dev/null +++ b/webpack/__mocks__/foremanReact/redux/middlewares/IntervalMiddleware/index.js @@ -0,0 +1,9 @@ +export const stopInterval = key => ({ + type: 'STOP_INTERVAL', + key, +}); + +export const withInterval = key => ({ + type: 'WITH_INTERVAL', + key, +});