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;