diff --git a/app/helpers/remote_execution_helper.rb b/app/helpers/remote_execution_helper.rb index 50f2a1d83..7e95d19e8 100644 --- a/app/helpers/remote_execution_helper.rb +++ b/app/helpers/remote_execution_helper.rb @@ -98,6 +98,11 @@ def job_invocation_task_buttons(task) :disabled => !task.cancellable?, :method => :post) end + if Setting[:lab_features] + buttons << link_to(_('New UI'), new_job_invocation_detail_path(:id => job_invocation.id), + class: 'btn btn-default', + title: _('Switch to the new job invocation detail UI')) + end return buttons end diff --git a/app/views/api/v2/job_invocations/base.json.rabl b/app/views/api/v2/job_invocations/base.json.rabl index 6978b7c18..7e253b7ae 100644 --- a/app/views/api/v2/job_invocations/base.json.rabl +++ b/app/views/api/v2/job_invocations/base.json.rabl @@ -3,7 +3,11 @@ object @job_invocation attributes :id, :description, :job_category, :targeting_id, :status, :start_at, :status_label, :ssh_user, :time_to_pickup node do |invocation| + pattern_template = invocation.pattern_template_invocations.first { + :template_id => pattern_template&.template_id, + :template_name => pattern_template&.template_name, + :effective_user => pattern_template&.effective_user, :succeeded => invocation_count(invocation, :output_key => :success_count), :failed => invocation_count(invocation, :output_key => :failed_count), :pending => invocation_count(invocation, :output_key => :pending_count), @@ -15,3 +19,13 @@ end child :task => :dynflow_task do attributes :id, :state 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)), + } + end +end diff --git a/config/routes.rb b/config/routes.rb index 493d5b937..51d97f21b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -22,6 +22,8 @@ match 'job_invocations/:id/rerun', to: 'react#index', :via => [:get], as: 'rerun_job_invocation' match 'old/job_invocations/new', to: 'job_invocations#new', via: [:get], as: 'form_new_job_invocation' match 'old/job_invocations/:id/rerun', to: 'job_invocations#rerun', via: [:get, :post], as: 'form_rerun_job_invocation' + match 'experimental/job_invocations_detail/:id', to: 'react#index', :via => [:get], as: 'new_job_invocation_detail' + resources :job_invocations, :only => [:create, :show, :index] do collection do get 'preview_job_invocations_per_host' diff --git a/lib/foreman_remote_execution/engine.rb b/lib/foreman_remote_execution/engine.rb index 1db82b925..41e097b79 100644 --- a/lib/foreman_remote_execution/engine.rb +++ b/lib/foreman_remote_execution/engine.rb @@ -244,6 +244,12 @@ class Engine < ::Rails::Engine parent: :monitor_menu, after: :audits + menu :labs_menu, :job_invocations_detail, + url_hash: { controller: :job_invocations, action: :show }, + caption: N_('Job invocations detail'), + parent: :lab_features_menu, + url: '/experimental/job_invocations_detail/1' + register_custom_status HostStatus::ExecutionStatus # add dashboard widget # widget 'foreman_remote_execution_widget', name: N_('Foreman plugin template widget'), sizex: 4, sizey: 1 diff --git a/webpack/JobInvocationDetail/JobInvocationActions.js b/webpack/JobInvocationDetail/JobInvocationActions.js new file mode 100644 index 000000000..6d0949c7c --- /dev/null +++ b/webpack/JobInvocationDetail/JobInvocationActions.js @@ -0,0 +1,22 @@ +import { get } from 'foremanReact/redux/API'; +import { + withInterval, + stopInterval, +} from 'foremanReact/redux/middlewares/IntervalMiddleware'; +import { JOB_INVOCATION_KEY } from './JobInvocationConstants'; + +export const getData = url => dispatch => { + const fetchData = withInterval( + get({ + key: JOB_INVOCATION_KEY, + params: { include_permissions: true }, + url, + handleError: () => { + dispatch(stopInterval(JOB_INVOCATION_KEY)); + }, + }), + 1000 + ); + + dispatch(fetchData); +}; diff --git a/webpack/JobInvocationDetail/JobInvocationConstants.js b/webpack/JobInvocationDetail/JobInvocationConstants.js new file mode 100644 index 000000000..239460d5b --- /dev/null +++ b/webpack/JobInvocationDetail/JobInvocationConstants.js @@ -0,0 +1,7 @@ +export const JOB_INVOCATION_KEY = 'JOB_INVOCATION_KEY'; + +export const STATUS = { + PENDING: 'pending', + SUCCEEDED: 'succeeded', + FAILED: 'failed', +}; diff --git a/webpack/JobInvocationDetail/JobInvocationOverview.js b/webpack/JobInvocationDetail/JobInvocationOverview.js new file mode 100644 index 000000000..25f62f886 --- /dev/null +++ b/webpack/JobInvocationDetail/JobInvocationOverview.js @@ -0,0 +1,104 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + DescriptionList, + DescriptionListTerm, + DescriptionListGroup, + DescriptionListDescription, +} from '@patternfly/react-core'; +import DefaultLoaderEmptyState from 'foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState'; +import { translate as __, documentLocale } from 'foremanReact/common/I18n'; + +const JobInvocationOverview = ({ data }) => { + const { + start_at: startAt, + ssh_user: sshUser, + template_id: templateId, + template_name: templateName, + effective_user: effectiveUser, + permissions, + } = data; + const canEditJobTemplates = permissions + ? permissions.edit_job_templates + : false; + const dateOptions = { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZoneName: 'short', + }; + let formattedStartDate = __('Not yet'); + + if (startAt) { + // Ensures date string compatibility across browsers + const convertedDate = new Date(startAt.replace(/[-.]/g, '/')); + if (convertedDate.getTime() <= new Date().getTime()) { + formattedStartDate = convertedDate.toLocaleString( + documentLocale(), + dateOptions + ); + } + } + + return ( + + + {__('Effective user:')} + + {effectiveUser || } + + + + {__('Started at:')} + + {formattedStartDate} + + + + {__('SSH user:')} + + {sshUser || } + + + + {__('Template:')} + + {templateName ? ( + + ) : ( + + )} + + + + ); +}; + +JobInvocationOverview.propTypes = { + data: PropTypes.object.isRequired, +}; + +export default JobInvocationOverview; diff --git a/webpack/JobInvocationDetail/JobInvocationSelectors.js b/webpack/JobInvocationDetail/JobInvocationSelectors.js new file mode 100644 index 000000000..6c0cf4d68 --- /dev/null +++ b/webpack/JobInvocationDetail/JobInvocationSelectors.js @@ -0,0 +1,5 @@ +import { selectAPIResponse } from 'foremanReact/redux/API/APISelectors'; +import { JOB_INVOCATION_KEY } from './JobInvocationConstants'; + +export const selectItems = state => + selectAPIResponse(state, JOB_INVOCATION_KEY); diff --git a/webpack/JobInvocationDetail/index.js b/webpack/JobInvocationDetail/index.js new file mode 100644 index 000000000..032cfb1c9 --- /dev/null +++ b/webpack/JobInvocationDetail/index.js @@ -0,0 +1,77 @@ +import React, { useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import PropTypes from 'prop-types'; +import { Divider, PageSection, Flex, FlexItem } from '@patternfly/react-core'; +import { translate as __ } from 'foremanReact/common/I18n'; +import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout'; +import { stopInterval } from 'foremanReact/redux/middlewares/IntervalMiddleware'; +import { getData } from './JobInvocationActions'; +import { selectItems } from './JobInvocationSelectors'; +import JobInvocationOverview from './JobInvocationOverview'; +import { JOB_INVOCATION_KEY, STATUS } from './JobInvocationConstants'; + +const JobInvocationDetailPage = ({ + match: { + params: { id }, + }, +}) => { + const dispatch = useDispatch(); + const items = useSelector(selectItems); + const { description, status_label: statusLabel, task } = items; + const finished = + statusLabel === STATUS.FAILED || statusLabel === STATUS.SUCCEEDED; + const autoRefresh = task?.state === STATUS.PENDING || false; + + useEffect(() => { + dispatch(getData(`/api/job_invocations/${id}`)); + if (finished && !autoRefresh) { + dispatch(stopInterval(JOB_INVOCATION_KEY)); + } + return () => { + dispatch(stopInterval(JOB_INVOCATION_KEY)); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, id, finished, autoRefresh]); + + const breadcrumbOptions = { + breadcrumbItems: [ + { caption: __('Jobs'), url: `/job_invocations` }, + { caption: description }, + ], + isPf4: true, + }; + + return ( + + + + + + + + + + + + + + ); +}; + +JobInvocationDetailPage.propTypes = { + match: PropTypes.shape({ + params: PropTypes.shape({ + id: PropTypes.string.isRequired, + }), + }).isRequired, +}; + +export default JobInvocationDetailPage; diff --git a/webpack/Routes/routes.js b/webpack/Routes/routes.js index d243877d0..3f064d767 100644 --- a/webpack/Routes/routes.js +++ b/webpack/Routes/routes.js @@ -1,6 +1,7 @@ import React from 'react'; import JobWizardPage from '../JobWizard'; import JobWizardPageRerun from '../JobWizard/JobWizardPageRerun'; +import JobInvocationDetailPage from '../JobInvocationDetail'; const ForemanREXRoutes = [ { @@ -13,6 +14,11 @@ const ForemanREXRoutes = [ exact: true, render: props => , }, + { + path: '/experimental/job_invocations_detail/:id', + exact: true, + render: props => , + }, ]; export default ForemanREXRoutes;