From 45bd8b894424c9316516001fa0c44a0cedd70a2b Mon Sep 17 00:00:00 2001 From: MariaAga Date: Fri, 24 Jan 2025 16:47:15 +0000 Subject: [PATCH] Fixes #38123 - add template invocation info to new job details --- .../template_invocations_controller.rb | 57 +++++ .../api/v2/job_invocations/hosts.json.rabl | 2 +- config/routes.rb | 2 + lib/foreman_remote_execution/engine.rb | 6 +- .../JobInvocationConstants.js | 19 ++ .../JobInvocationDetail.scss | 61 +++++ .../JobInvocationHostTable.js | 148 ++++++++---- .../JobInvocationSelectors.js | 9 +- .../JobInvocationDetail/OpenAlInvocations.js | 111 +++++++++ .../JobInvocationDetail/TemplateInvocation.js | 202 ++++++++++++++++ .../OutputCodeBlock.js | 124 ++++++++++ .../OutputToggleGroup.js | 156 ++++++++++++ .../PreviewTemplate.js | 50 ++++ .../TemplateActionButtons.js | 224 ++++++++++++++++++ .../TemplateInvocationPage.js | 53 +++++ .../__tests__/OpenAlInvocations.test.js | 110 +++++++++ .../__tests__/OutputCodeBlock.test.js | 69 ++++++ .../__tests__/TemplateInvocation.test.js | 131 ++++++++++ .../JobInvocationDetail/__tests__/fixtures.js | 110 +++++++++ webpack/Routes/routes.js | 6 + 20 files changed, 1603 insertions(+), 47 deletions(-) create mode 100644 webpack/JobInvocationDetail/OpenAlInvocations.js create mode 100644 webpack/JobInvocationDetail/TemplateInvocation.js create mode 100644 webpack/JobInvocationDetail/TemplateInvocationComponents/OutputCodeBlock.js create mode 100644 webpack/JobInvocationDetail/TemplateInvocationComponents/OutputToggleGroup.js create mode 100644 webpack/JobInvocationDetail/TemplateInvocationComponents/PreviewTemplate.js create mode 100644 webpack/JobInvocationDetail/TemplateInvocationComponents/TemplateActionButtons.js create mode 100644 webpack/JobInvocationDetail/TemplateInvocationPage.js create mode 100644 webpack/JobInvocationDetail/__tests__/OpenAlInvocations.test.js create mode 100644 webpack/JobInvocationDetail/__tests__/OutputCodeBlock.test.js create mode 100644 webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js diff --git a/app/controllers/template_invocations_controller.rb b/app/controllers/template_invocations_controller.rb index 1ddfb0633..a3f6dca1d 100644 --- a/app/controllers/template_invocations_controller.rb +++ b/app/controllers/template_invocations_controller.rb @@ -1,5 +1,10 @@ class TemplateInvocationsController < ApplicationController include Foreman::Controller::AutoCompleteSearch + include RemoteExecutionHelper + include JobInvocationsHelper + + before_action :find_job_invocation, :only => %w{show_template_invocation_by_host} + before_action :find_host, :only => %w{show_template_invocation_by_host} def controller_permission 'job_invocations' @@ -17,4 +22,56 @@ def show @line_sets = @line_sets.drop_while { |o| o['timestamp'].to_f <= @since } if @since @line_counter = params[:line_counter].to_i end + + def show_template_invocation_by_host + @template_invocation = @job_invocation.template_invocations.find { |template_inv| template_inv.host_id == @host.id } + if @template_invocation.nil? + render :json => { :error => _('Template invocation not found') }, :status => :not_found + end + @template_invocation_task = @template_invocation.run_host_job_task + + lines = normalize_line_sets(@template_invocation_task.main_action.live_output) + transformed_input_values = @template_invocation.input_values.joins(:template_input).map do |input_value| + { + name: input_value&.template_input&.name, + value: input_safe_value(input_value), + } + end + + auto_refresh = @job_invocation.task.try(:pending?) + finished = @job_invocation.status_label == 'failed' || @job_invocation.status_label == 'succeeded' || @job_invocation.status_label == 'cancelled' + render :json => { :output => lines, :preview => template_invocation_preview(@template_invocation, @host), :input_values => transformed_input_values, :job_invocation_description => @job_invocation.description, :task_id => @template_invocation_task.id, :task_cancellable => @template_invocation_task.cancellable?, :host_name => @host.name, :permissions => { + :view_foreman_tasks => User.current.allowed_to?(:view_foreman_tasks), + :cancel_job_invocations => User.current.allowed_to?(:cancel_job_invocations), + :execute_jobs => User.current.allowed_to?(:create_job_invocations) && (!@host.infrastructure_host? || User.current.can?(:execute_jobs_on_infrastructure_hosts)), + + }, + :auto_refresh => auto_refresh, :finished => finished}, status: :ok + end + + private + + def find_job_invocation + @job_invocation = JobInvocation.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render :json => { :error => { :message => format(_("Job with id '%{id}' was not found"), :id => params['id']) } }, :status => :not_found + end + + def find_host + @host = Host.find(params[:host_id]) + rescue ActiveRecord::RecordNotFound + render :json => { :error => { :message => format(_("Host with id '%{id}' was not found"), :id => params['host_id']) } }, :status => :not_found + end + + def template_invocation_preview(template_invocation, host) + renderer = InputTemplateRenderer.new(template_invocation.template, host, template_invocation) + output = load_template_from_task(template_invocation, host) || renderer.preview + if output + {:plain => output} + else + {status: :bad_request, + plain: renderer.error_message } + end + end + end diff --git a/app/views/api/v2/job_invocations/hosts.json.rabl b/app/views/api/v2/job_invocations/hosts.json.rabl index 0467f9ce5..7e627dfef 100644 --- a/app/views/api/v2/job_invocations/hosts.json.rabl +++ b/app/views/api/v2/job_invocations/hosts.json.rabl @@ -1,6 +1,6 @@ collection @hosts -attribute :name, :operatingsystem_id, :operatingsystem_name, :hostgroup_id, :hostgroup_name +attribute :name, :operatingsystem_id, :operatingsystem_name, :hostgroup_id, :hostgroup_name, :id node :job_status do |host| @host_statuses[host.id] diff --git a/config/routes.rb b/config/routes.rb index 3cf6759ac..7f83b812c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,6 +23,8 @@ 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' + match 'job_invocations_detail/:id/host_invocation/:host_id', to: 'react#index', :via => [:get], as: 'new_job_invocation_detail_by_host' + get 'show_template_invocation_by_host/:host_id/job_invocation/:id', to: 'template_invocations#show_template_invocation_by_host' resources :job_invocations, :only => [:create, :show, :index] do collection do diff --git a/lib/foreman_remote_execution/engine.rb b/lib/foreman_remote_execution/engine.rb index d6d5e5975..9cef9032c 100644 --- a/lib/foreman_remote_execution/engine.rb +++ b/lib/foreman_remote_execution/engine.rb @@ -35,7 +35,7 @@ class Engine < ::Rails::Engine initializer 'foreman_remote_execution.register_plugin', before: :finisher_hook do |app| app.reloader.to_prepare do Foreman::Plugin.register :foreman_remote_execution do - requires_foreman '>= 3.13' + requires_foreman '>= 3.14' register_global_js_file 'global' register_gettext @@ -170,9 +170,9 @@ class Engine < ::Rails::Engine permission :lock_job_templates, { :job_templates => [:lock, :unlock] }, :resource_type => 'JobTemplate' permission :create_job_invocations, { :job_invocations => [:new, :create, :legacy_create, :refresh, :rerun, :preview_hosts], 'api/v2/job_invocations' => [:create, :rerun] }, :resource_type => 'JobInvocation' - permission :view_job_invocations, { :job_invocations => [:index, :chart, :show, :auto_complete_search, :preview_job_invocations_per_host], :template_invocations => [:show], + permission :view_job_invocations, { :job_invocations => [:index, :chart, :show, :auto_complete_search, :preview_job_invocations_per_host], :template_invocations => [:show, :show_template_invocation_by_host], 'api/v2/job_invocations' => [:index, :show, :output, :raw_output, :outputs, :hosts] }, :resource_type => 'JobInvocation' - permission :view_template_invocations, { :template_invocations => [:show], + permission :view_template_invocations, { :template_invocations => [:show, :template_invocation_preview, :show_template_invocation_by_host], 'api/v2/template_invocations' => [:template_invocations], :ui_job_wizard => [:job_invocation] }, :resource_type => 'TemplateInvocation' permission :create_template_invocations, {}, :resource_type => 'TemplateInvocation' permission :execute_jobs_on_infrastructure_hosts, {}, :resource_type => 'JobInvocation' diff --git a/webpack/JobInvocationDetail/JobInvocationConstants.js b/webpack/JobInvocationDetail/JobInvocationConstants.js index 5ac06d4c1..a99bad188 100644 --- a/webpack/JobInvocationDetail/JobInvocationConstants.js +++ b/webpack/JobInvocationDetail/JobInvocationConstants.js @@ -19,6 +19,15 @@ export const JOB_INVOCATION_HOSTS = 'JOB_INVOCATION_HOSTS'; export const currentPermissionsUrl = foremanUrl( '/api/v2/permissions/current_permissions' ); +export const GET_TEMPLATE_INVOCATION = 'GET_TEMPLATE_INVOCATION'; +export const showTemplateInvocationUrl = (hostID, jobID) => + `/show_template_invocation_by_host/${hostID}/job_invocation/${jobID}`; + +export const templateInvocationPageUrl = (hostID, jobID) => + `/job_invocations_detail/${jobID}/host_invocation/${hostID}`; + +export const jobInvocationDetailsUrl = id => + `/experimental/job_invocations_detail/${id}`; export const STATUS = { PENDING: 'pending', @@ -65,6 +74,11 @@ const Columns = () => { const hostDetailsPageUrl = useForemanHostDetailsPageUrl(); return { + expand: { + title: '', + weight: 0, + wrapper: () => null, + }, name: { title: __('Name'), wrapper: ({ name }) => ( @@ -109,6 +123,11 @@ const Columns = () => { }, weight: 5, }, + actions: { + title: '', + weight: 6, + wrapper: () => null, + }, }; }; diff --git a/webpack/JobInvocationDetail/JobInvocationDetail.scss b/webpack/JobInvocationDetail/JobInvocationDetail.scss index 32eec7fcf..bf0287c44 100644 --- a/webpack/JobInvocationDetail/JobInvocationDetail.scss +++ b/webpack/JobInvocationDetail/JobInvocationDetail.scss @@ -45,3 +45,64 @@ .job-invocation-details section:nth-child(3) { padding-bottom: 0; } + +.template-invocation { + &.output-in-table-view { + div.invocation-output { + overflow: auto; + max-height: 25em; + } + } + div.invocation-output { + display: block; + padding: 9.5px; + margin: 0 0 10px; + font-size: 12px; + word-break: break-all; + word-wrap: break-word; + color: rgba(255, 255, 255, 1); + background-color: rgba(47, 47, 47, 1); + border: 1px solid #000000; + border-radius: 0px; + font-family: Menlo, Monaco, Consolas, monospace; + + div.printable { + min-height: 50px; + } + + div.line.stderr, + div.line.error, + div.line.debug { + color: red; + } + + div.line span.counter { + float: left; + clear: left; + } + + div.line div.content { + position: relative; + margin-left: 50px; + white-space: pre-wrap; + } + + a { + color: #ffffff; + } + + a.scroll-link{ + position: relative; + bottom: 10px; + float: right; + } + } + + .template-invocation-preview { + margin-top: 10px; + } + + .pf-c-toggle-group { + margin-bottom: 10px; + } +} diff --git a/webpack/JobInvocationDetail/JobInvocationHostTable.js b/webpack/JobInvocationDetail/JobInvocationHostTable.js index 0f6312313..89dd9e548 100644 --- a/webpack/JobInvocationDetail/JobInvocationHostTable.js +++ b/webpack/JobInvocationDetail/JobInvocationHostTable.js @@ -1,10 +1,10 @@ /* eslint-disable camelcase */ import PropTypes from 'prop-types'; -import React, { useMemo, useEffect } from 'react'; +import React, { useMemo, useEffect, useState } from 'react'; import { Icon } from 'patternfly-react'; import { translate as __ } from 'foremanReact/common/I18n'; import { FormattedMessage } from 'react-intl'; -import { Tr, Td } from '@patternfly/react-table'; +import { Tr, Td, Tbody, ExpandableRowContent } from '@patternfly/react-table'; import { Title, EmptyState, @@ -26,6 +26,9 @@ import Columns, { JOB_INVOCATION_HOSTS, STATUS_UPPERCASE, } from './JobInvocationConstants'; +import { TemplateInvocation } from './TemplateInvocation'; +import { OpenAlInvocations, PopupAlert } from './OpenAlInvocations'; +import { RowActions } from './TemplateInvocationComponents/TemplateActionButtons'; const JobInvocationHostTable = ({ id, targeting, finished, autoRefresh }) => { const columns = Columns(); @@ -39,11 +42,18 @@ const JobInvocationHostTable = ({ id, targeting, finished, autoRefresh }) => { const defaultParams = { search: urlSearchQuery }; if (urlPage) defaultParams.page = Number(urlPage); if (urlPerPage) defaultParams.per_page = Number(urlPerPage); + const [expandedHost, setExpandedHost] = useState([]); const { response, status, setAPIOptions } = useAPI( 'get', `/api/job_invocations/${id}/hosts`, { - params: { ...defaultParams, key: JOB_INVOCATION_HOSTS }, + params: { + ...defaultParams, + }, + key: JOB_INVOCATION_HOSTS, + handleSuccess: ({ data }) => { + if (data?.results?.length === 1) setExpandedHost([data.results[0].id]); + }, } ); @@ -153,48 +163,102 @@ const JobInvocationHostTable = ({ id, targeting, finished, autoRefresh }) => { ); + const { results = [] } = response; + + const isHostExpanded = host => expandedHost.includes(host); + const setHostExpanded = (host, isExpanding = true) => + setExpandedHost(prevExpanded => { + const otherExpandedHosts = prevExpanded.filter(h => h !== host); + return isExpanding ? [...otherExpandedHosts, host] : otherExpandedHosts; + }); + const [showAlert, setShowAlert] = useState(false); return ( - - {}} - errorMessage={ - status === STATUS_UPPERCASE.ERROR && response?.message - ? response.message - : null + <> + {showAlert && } + } - isPending={status === STATUS_UPPERCASE.PENDING} - isDeleteable={false} - bottomPagination={bottomPagination} > - {response?.results?.map((result, rowIndex) => ( - - {columnNamesKeys.map(k => ( - - ))} - - ))} -
{columns[k].wrapper(result)}
-
+ {}} + errorMessage={ + status === STATUS_UPPERCASE.ERROR && response?.message + ? response.message + : null + } + isPending={status === STATUS_UPPERCASE.PENDING} + isDeleteable={false} + bottomPagination={bottomPagination} + childrenOutsideTbody + > + {results?.map((result, rowIndex) => ( + + + + ))} + + + + + + + ))} +
+ setHostExpanded(result.id, !isHostExpanded(result.id)), + expandId: 'host-expandable', + }} + /> + {columnNamesKeys.slice(1).map(k => ( + {columns[k].wrapper(result)} + +
+ + {result.job_status === 'cancelled' || + result.job_status === 'N/A' ? ( +
+ {__('A task for this host has not been started')} +
+ ) : ( + + )} +
+
+ + ); }; diff --git a/webpack/JobInvocationDetail/JobInvocationSelectors.js b/webpack/JobInvocationDetail/JobInvocationSelectors.js index f5ba99d66..6891f272b 100644 --- a/webpack/JobInvocationDetail/JobInvocationSelectors.js +++ b/webpack/JobInvocationDetail/JobInvocationSelectors.js @@ -1,5 +1,9 @@ import { selectAPIResponse } from 'foremanReact/redux/API/APISelectors'; -import { JOB_INVOCATION_KEY, GET_TASK } from './JobInvocationConstants'; +import { + JOB_INVOCATION_KEY, + GET_TASK, + GET_TEMPLATE_INVOCATION, +} from './JobInvocationConstants'; export const selectItems = state => selectAPIResponse(state, JOB_INVOCATION_KEY); @@ -8,3 +12,6 @@ export const selectTask = state => selectAPIResponse(state, GET_TASK); export const selectTaskCancelable = state => selectTask(state).available_actions?.cancellable || false; + +export const selectTemplateInvocation = state => + selectAPIResponse(state, GET_TEMPLATE_INVOCATION); diff --git a/webpack/JobInvocationDetail/OpenAlInvocations.js b/webpack/JobInvocationDetail/OpenAlInvocations.js new file mode 100644 index 000000000..33f65fb40 --- /dev/null +++ b/webpack/JobInvocationDetail/OpenAlInvocations.js @@ -0,0 +1,111 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Alert, + AlertActionCloseButton, + Button, + Modal, + ModalVariant, +} from '@patternfly/react-core'; +import { OutlinedWindowRestoreIcon } from '@patternfly/react-icons'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { templateInvocationPageUrl } from './JobInvocationConstants'; + +export const PopupAlert = ({ setShowAlert }) => ( + setShowAlert(false)} />} + title={__( + 'Popups are blocked by your browser. Please allow popups for this site to open all invocations in new tabs.' + )} + /> +); +export const OpenAlInvocations = ({ results, id, setShowAlert }) => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + const handleModalToggle = () => { + setIsModalOpen(!isModalOpen); + }; + + const openLink = url => { + const newWin = window.open(url); + + if (!newWin || newWin.closed || typeof newWin.closed === 'undefined') { + setShowAlert(true); + } + }; + const OpenAllButton = () => ( + + ); + const OpenAllModal = () => ( + { + results.forEach(result => { + openLink(templateInvocationPageUrl(result.id, id), '_blank'); + }); + handleModalToggle(); + }} + > + {__('Open all in new tabs')} + , + , + ]} + > + {__('Are you sure you want to open all invocations in new tabs?')} +
+ {__('This will open a new tab for each invocation.')} +
+ {__('The number of invocations is:')} {results.length} +
+ ); + return ( + <> + + + + ); +}; + +OpenAlInvocations.propTypes = { + results: PropTypes.array.isRequired, + id: PropTypes.string.isRequired, + setShowAlert: PropTypes.func.isRequired, +}; + +PopupAlert.propTypes = { + setShowAlert: PropTypes.func.isRequired, +}; diff --git a/webpack/JobInvocationDetail/TemplateInvocation.js b/webpack/JobInvocationDetail/TemplateInvocation.js new file mode 100644 index 000000000..80fe8f6a4 --- /dev/null +++ b/webpack/JobInvocationDetail/TemplateInvocation.js @@ -0,0 +1,202 @@ +import React, { useState, useEffect } from 'react'; +import { isEmpty } from 'lodash'; +import PropTypes from 'prop-types'; +import { ClipboardCopyButton, Alert, Skeleton } from '@patternfly/react-core'; +import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { useForemanHostDetailsPageUrl } from 'foremanReact/Root/Context/ForemanContext'; +import { STATUS } from 'foremanReact/constants'; +import { + showTemplateInvocationUrl, + templateInvocationPageUrl, + GET_TEMPLATE_INVOCATION, +} from './JobInvocationConstants'; +import { OutputToggleGroup } from './TemplateInvocationComponents/OutputToggleGroup'; +import { PreviewTemplate } from './TemplateInvocationComponents/PreviewTemplate'; +import { OutputCodeBlock } from './TemplateInvocationComponents/OutputCodeBlock'; + +const CopyToClipboard = ({ fullOutput }) => { + const clipboardCopyFunc = async (event, text) => { + try { + await navigator.clipboard.writeText(text.toString()); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error?.message); + } + }; + + const onClick = (event, text) => { + clipboardCopyFunc(event, text); + setCopied(true); + }; + const [copied, setCopied] = React.useState(false); + return ( + onClick(e, fullOutput)} + exitDelay={copied ? 1500 : 600} + maxWidth="110px" + variant="plain" + onTooltipHidden={() => setCopied(false)} + > + {copied + ? __('Successfully copied to clipboard!') + : __('Copy to clipboard')} + + ); +}; +let intervalId; +export const TemplateInvocation = ({ + hostID, + jobID, + isInTableView, + hostName, +}) => { + const templateURL = showTemplateInvocationUrl(hostID, jobID); + const hostDetailsPageUrl = useForemanHostDetailsPageUrl(); + const { response, status, setAPIOptions } = useAPI('get', templateURL, { + key: GET_TEMPLATE_INVOCATION, + headers: { Accept: 'application/json' }, + handleError: () => { + if (intervalId) clearInterval(intervalId); + }, + }); + const { finished, auto_refresh: autoRefresh } = response; + + useEffect(() => { + if (!finished || autoRefresh) { + intervalId = setInterval(() => { + // Re call api + setAPIOptions(prevOptions => ({ + ...prevOptions, + })); + }, 5000); + } + if (intervalId && finished && !autoRefresh) { + clearInterval(intervalId); + } + return () => { + clearInterval(intervalId); + }; + }, [finished, autoRefresh, setAPIOptions]); + + const errorMessage = + response?.response?.data?.error?.message || + response?.response?.data?.error || + JSON.stringify(response); + const { + preview, + output, + input_values: inputValues, + task_id: taskID, + task_cancellable: taskCancellable, + permissions, + } = response; + const [showOutputType, setShowOutputType] = useState({ + stderr: true, + stdout: true, + debug: true, + }); + const [showTemplatePreview, setShowTemplatePreview] = useState(false); + const [showCommand, setCommand] = useState(false); + if (status === STATUS.PENDING && isEmpty(response)) { + return ; + } else if (status === STATUS.ERROR) { + return ( + + {errorMessage} + + ); + } + + return ( +
+ showOutputType[outputType] + ) + .map(({ output: _output }) => _output) + .join('\n')} + /> + } + taskID={taskID} + jobID={jobID} + hostID={hostID} + taskCancellable={taskCancellable} + permissions={permissions} + /> + {!isInTableView && ( +
+ {__('Target:')}{' '} + {hostName} +
+ )} + {showTemplatePreview && } + {showCommand && ( + <> + {preview?.status ? ( + + ) : ( +
{preview.plain}
+ )} + + )} + +
+ ); +}; + +TemplateInvocation.propTypes = { + hostID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + hostName: PropTypes.string, // only used when isInTableView is false + jobID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + isInTableView: PropTypes.bool, +}; + +TemplateInvocation.defaultProps = { + isInTableView: true, + hostName: '', +}; + +CopyToClipboard.propTypes = { + fullOutput: PropTypes.string, +}; +CopyToClipboard.defaultProps = { + fullOutput: '', +}; diff --git a/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputCodeBlock.js b/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputCodeBlock.js new file mode 100644 index 000000000..48fe532df --- /dev/null +++ b/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputCodeBlock.js @@ -0,0 +1,124 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button } from '@patternfly/react-core'; +import { translate as __ } from 'foremanReact/common/I18n'; + +export const OutputCodeBlock = ({ code, showOutputType, scrollElement }) => { + let lineCounter = 0; + // eslint-disable-next-line no-control-regex + const COLOR_PATTERN = /\x1b\[(\d+)m/g; + const CONSOLE_COLOR = { + '31': 'red', + '32': 'lightgreen', + '33': 'orange', + '34': 'deepskyblue', + '35': 'mediumpurple', + '36': 'cyan', + '37': 'grey', + '91': 'red', + '92': 'lightgreen', + '93': 'yellow', + '94': 'lightblue', + '95': 'violet', + '96': 'turquoise', + '0': 'default', + }; + + const colorizeLine = line => { + line = line.replace(COLOR_PATTERN, seq => { + const color = seq.match(/(\d+)m/)[1]; + return `{{{format color:${color}}}}`; + }); + + let currentColor = 'default'; + const parts = line.split(/({{{format.*?}}})/).filter(Boolean); + if (parts.length === 0) { + return {'\n'}; + } + // eslint-disable-next-line array-callback-return, consistent-return + return parts.map((consoleLine, index) => { + if (consoleLine.includes('{{{format')) { + const colorMatch = consoleLine.match(/color:(\d+)/); + if (colorMatch) { + const colorIndex = colorMatch[1]; + currentColor = CONSOLE_COLOR[colorIndex] || 'default'; + } + } else { + return ( + + {consoleLine.length ? consoleLine : '\n'} + + ); + } + }); + }; + const filteredCode = code.filter( + ({ output_type: outputType }) => showOutputType[outputType] + ); + if (!filteredCode.length) { + return
{__('No output for the selected filters')}
; + } + const codeParse = filteredCode.map(line => { + if (line.output === '\n') { + return null; + } + const lineOutputs = line.output + .replace(/\r\n/g, '\n') + .replace(/\n$/, '') + .split('\n'); + return lineOutputs.map((lineOutput, index) => { + lineCounter += 1; + return ( +
+ + {lineCounter.toString().padStart(4, '\u00A0')}:{' '} + +
{colorizeLine(lineOutput)}
+
+ ); + }); + }); + const scrollElementSeleceted = () => document.querySelector(scrollElement); + const onClickScrollToTop = () => { + scrollElementSeleceted().scrollTo(0, 0); + }; + const onClickScrollToBottom = () => { + scrollElementSeleceted().scrollTo(0, scrollElementSeleceted().scrollHeight); + }; + return ( +
+ + {codeParse} + +
+ ); +}; + +OutputCodeBlock.propTypes = { + code: PropTypes.array.isRequired, + showOutputType: PropTypes.object.isRequired, + scrollElement: PropTypes.string.isRequired, +}; diff --git a/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputToggleGroup.js b/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputToggleGroup.js new file mode 100644 index 000000000..1a6b4e98c --- /dev/null +++ b/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputToggleGroup.js @@ -0,0 +1,156 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + ToggleGroup, + ToggleGroupItem, + Flex, + FlexItem, + Button, +} from '@patternfly/react-core'; +import { OutlinedWindowRestoreIcon } from '@patternfly/react-icons'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { TemplateActionButtons } from './TemplateActionButtons'; + +export const OutputToggleGroup = ({ + showOutputType, + setShowOutputType, + showTemplatePreview, + setShowTemplatePreview, + showCommand, + setShowCommand, + newTabUrl, + isInTableView, + copyToClipboard, + taskID, + jobID, + hostID, + taskCancellable, + permissions, +}) => { + const handleSTDERRClick = _isSelected => { + setShowOutputType(prevShowOutputType => ({ + ...prevShowOutputType, + stderr: _isSelected, + })); + }; + + const handleSTDOUTClick = _isSelected => { + setShowOutputType(prevShowOutputType => ({ + ...prevShowOutputType, + stdout: _isSelected, + })); + }; + const handleDEBUGClick = _isSelected => { + setShowOutputType(prevShowOutputType => ({ + ...prevShowOutputType, + debug: _isSelected, + })); + }; + const handlePreviewTemplateClick = _isSelected => { + setShowTemplatePreview(_isSelected); + }; + const handleCommandClick = _isSelected => { + setShowCommand(_isSelected); + }; + + const toggleGroupItems = { + stderr: { + id: 'stderr-toggle', + text: __('STDERR'), + onClick: handleSTDERRClick, + isSelected: showOutputType.stderr, + }, + stdout: { + id: 'stdout-toggle', + text: __('STDOUT'), + onClick: handleSTDOUTClick, + isSelected: showOutputType.stdout, + }, + debug: { + id: 'debug-toggle', + text: __('DEBUG'), + onClick: handleDEBUGClick, + isSelected: showOutputType.debug, + }, + previewTemplate: { + id: 'preview-template-toggle', + text: __('Preview Template'), + onClick: handlePreviewTemplateClick, + isSelected: showTemplatePreview, + }, + command: { + id: 'command-toggle', + text: __('Command'), + onClick: handleCommandClick, + isSelected: showCommand, + }, + }; + + return ( + + + + {Object.values(toggleGroupItems).map( + ({ id, text, onClick, isSelected }) => ( + + ) + )} + + + {isInTableView ? null : ( + + )} + {copyToClipboard} + {isInTableView && ( + + + + )} + + ); +}; + +OutputToggleGroup.propTypes = { + showOutputType: PropTypes.shape({ + stderr: PropTypes.bool, + stdout: PropTypes.bool, + debug: PropTypes.bool, + }).isRequired, + setShowOutputType: PropTypes.func.isRequired, + setShowTemplatePreview: PropTypes.func.isRequired, + showTemplatePreview: PropTypes.bool.isRequired, + showCommand: PropTypes.bool.isRequired, + setShowCommand: PropTypes.func.isRequired, + newTabUrl: PropTypes.string, + copyToClipboard: PropTypes.node.isRequired, + isInTableView: PropTypes.bool, + ...TemplateActionButtons.propTypes, +}; + +OutputToggleGroup.defaultProps = { + newTabUrl: null, + isInTableView: false, + ...TemplateActionButtons.defaultProps, +}; diff --git a/webpack/JobInvocationDetail/TemplateInvocationComponents/PreviewTemplate.js b/webpack/JobInvocationDetail/TemplateInvocationComponents/PreviewTemplate.js new file mode 100644 index 000000000..3e89e4f73 --- /dev/null +++ b/webpack/JobInvocationDetail/TemplateInvocationComponents/PreviewTemplate.js @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + TableComposable, + Thead, + Tr, + Th, + Tbody, + Td, +} from '@patternfly/react-table'; +import { translate as __ } from 'foremanReact/common/I18n'; + +export const PreviewTemplate = ({ inputValues }) => + inputValues.length ? ( + + + + {__('User input')} + {__('Value')} + + + + {inputValues.map(({ name, value }, index) => ( + + + {name} + + {value} + + ))} + + + ) : ( + {__('No user input')} + ); + +PreviewTemplate.propTypes = { + inputValues: PropTypes.array, +}; + +PreviewTemplate.defaultProps = { + inputValues: [], +}; diff --git a/webpack/JobInvocationDetail/TemplateInvocationComponents/TemplateActionButtons.js b/webpack/JobInvocationDetail/TemplateInvocationComponents/TemplateActionButtons.js new file mode 100644 index 000000000..dfbe7f800 --- /dev/null +++ b/webpack/JobInvocationDetail/TemplateInvocationComponents/TemplateActionButtons.js @@ -0,0 +1,224 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { Flex, FlexItem, Button } from '@patternfly/react-core'; +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 { selectTemplateInvocation } from '../JobInvocationSelectors'; + +const actions = ({ + taskID, + jobID, + hostID, + taskCancellable, + permissions, + dispatch, +}) => ({ + rerun: { + name: 'template-invocation-rerun-job', + href: `/job_invocations/${jobID}/rerun?host_ids[]=${hostID}`, + component: 'a', + text: __('Rerun'), + permission: permissions.execute_jobs, + }, + details: { + name: 'template-invocation-task-details', + href: `/foreman_tasks/tasks/${taskID}`, + component: 'a', + text: __('Task Details'), + permission: permissions.view_foreman_tasks, + }, + cancel: { + name: 'template-invocation-cancel-job', + text: __('Cancel Task'), + permission: permissions.cancel_job_invocations, + onClick: () => { + dispatch( + addToast({ + key: `cancel-job-info`, + type: 'info', + message: __('Trying to cancel the task for the host'), + }) + ); + dispatch( + APIActions.post({ + url: `/foreman_tasks/tasks/${taskID}/cancel`, + key: 'CANCEL_TASK', + errorToast: ({ response }) => response.data.message, + successToast: () => __('Task for the host cancelled succesfully'), + }) + ); + }, + isDisabled: !taskCancellable, + }, + abort: { + name: 'template-invocation-abort-job', + text: __('Abort task'), + permission: permissions.cancel_job_invocations, + onClick: () => { + dispatch( + addToast({ + key: `abort-job-info`, + type: 'info', + message: __('Trying to abort the task for the host'), + }) + ); + dispatch( + APIActions.post({ + url: `/foreman_tasks/tasks/${taskID}/abort`, + key: 'ABORT_TASK', + errorToast: ({ response }) => response.data.message, + successToast: () => __('task aborted succesfully'), + }) + ); + }, + isDisabled: !taskCancellable, + }, +}); + +export const RowActions = ({ hostID, jobID }) => { + const dispatch = useDispatch(); + const response = useSelector(selectTemplateInvocation); + if (!response?.permissions) return null; + const { + task_id: taskID, + task_cancellable: taskCancellable, + permissions, + } = response; + + const getActions = actions({ + taskID, + jobID, + hostID, + taskCancellable, + permissions, + dispatch, + }); + const rowActions = Object.values(getActions) + .map(({ text, onClick, href, permission, isDisabled }) => + permission + ? { + title: href ? ( + + {text} + + ) : ( + text + ), + onClick, + isDisabled, + } + : null + ) + .filter(Boolean); + + return ; +}; + +export const TemplateActionButtons = ({ + taskID, + jobID, + hostID, + taskCancellable, + permissions, +}) => { + const dispatch = useDispatch(); + const { rerun, details, cancel, abort } = actions({ + taskID, + jobID, + hostID, + taskCancellable, + permissions, + dispatch, + }); + return ( + + {rerun.permission && ( + + + + )} + {details.permission && ( + + + + )} + {cancel.permission && ( + + + + )} + {abort.permission && ( + + + + )} + + ); +}; +TemplateActionButtons.propTypes = { + taskID: PropTypes.string, + jobID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + hostID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + taskCancellable: PropTypes.bool, + permissions: PropTypes.shape({ + view_foreman_tasks: PropTypes.bool, + cancel_job_invocations: PropTypes.bool, + execute_jobs: PropTypes.bool, + }), +}; + +TemplateActionButtons.defaultProps = { + taskID: null, + taskCancellable: false, + permissions: { + view_foreman_tasks: false, + cancel_job_invocations: false, + execute_jobs: false, + }, +}; + +RowActions.propTypes = { + hostID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + jobID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, +}; diff --git a/webpack/JobInvocationDetail/TemplateInvocationPage.js b/webpack/JobInvocationDetail/TemplateInvocationPage.js new file mode 100644 index 000000000..16b2e9746 --- /dev/null +++ b/webpack/JobInvocationDetail/TemplateInvocationPage.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; +import { TemplateInvocation } from './TemplateInvocation'; +import { selectTemplateInvocation } from './JobInvocationSelectors'; +import { jobInvocationDetailsUrl } from './JobInvocationConstants'; + +const TemplateInvocationPage = ({ + match: { + params: { hostID, jobID }, + }, +}) => { + const { + job_invocation_description: jobDescription, + host_name: hostName, + } = useSelector(selectTemplateInvocation); + const description = sprintf(__('Template Invocation for %s'), hostName); + const breadcrumbOptions = { + breadcrumbItems: [ + { caption: __('Jobs'), url: `/job_invocations` }, + { caption: jobDescription, url: jobInvocationDetailsUrl(jobID) }, + { caption: hostName }, + ], + isPf4: true, + }; + return ( + + + + ); +}; + +TemplateInvocationPage.propTypes = { + match: PropTypes.shape({ + params: PropTypes.shape({ + jobID: PropTypes.string.isRequired, + hostID: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, +}; + +export default TemplateInvocationPage; diff --git a/webpack/JobInvocationDetail/__tests__/OpenAlInvocations.test.js b/webpack/JobInvocationDetail/__tests__/OpenAlInvocations.test.js new file mode 100644 index 000000000..f55bfaf10 --- /dev/null +++ b/webpack/JobInvocationDetail/__tests__/OpenAlInvocations.test.js @@ -0,0 +1,110 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { OpenAlInvocations, PopupAlert } from '../OpenAlInvocations'; +import { templateInvocationPageUrl } from '../JobInvocationConstants'; + +// Mock the templateInvocationPageUrl function +jest.mock('../JobInvocationConstants', () => ({ + ...jest.requireActual('../JobInvocationConstants'), + templateInvocationPageUrl: jest.fn((resultId, id) => `url/${resultId}/${id}`), +})); + +describe('OpenAlInvocations', () => { + const mockResults = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const mockSetShowAlert = jest.fn(); + let windowSpy; + const windowOpen = window.open; + + beforeAll(() => { + window.open = () => {}; + }); + afterAll(() => { + windowSpy.mockRestore(); + jest.clearAllMocks(); + window.open = windowOpen; + }); + + test('renders without crashing', () => { + render( + + ); + }); + + test('opens links when results length is less than or equal to 3', () => { + render( + + ); + + const button = screen.getByRole('button', { name: /open all/i }); + fireEvent.click(button); + + expect(templateInvocationPageUrl).toHaveBeenCalledTimes(mockResults.length); + mockResults.forEach(result => { + expect(templateInvocationPageUrl).toHaveBeenCalledWith( + result.id, + 'test-id' + ); + }); + }); + + test('shows modal when results length is greater than 3', () => { + const largeResults = [...mockResults, { id: 4 }]; + render( + + ); + + const button = screen.getByRole('button', { name: /open all/i }); + fireEvent.click(button); + + expect( + screen.getAllByText(/open all invocations in new tabs/i) + ).toHaveLength(2); + }); + + test('shows alert when popups are blocked', () => { + window.open = jest.fn().mockReturnValueOnce(null); + + render( + + ); + + const button = screen.getByRole('button', { name: /open all/i }); + fireEvent.click(button); + + expect(mockSetShowAlert).toHaveBeenCalledWith(true); + }); +}); + +describe('PopupAlert', () => { + const mockSetShowAlert = jest.fn(); + + test('renders without crashing', () => { + render(); + }); + + test('closes alert when close button is clicked', () => { + render(); + + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + expect(mockSetShowAlert).toHaveBeenCalledWith(false); + }); +}); diff --git a/webpack/JobInvocationDetail/__tests__/OutputCodeBlock.test.js b/webpack/JobInvocationDetail/__tests__/OutputCodeBlock.test.js new file mode 100644 index 000000000..cc3e0ee27 --- /dev/null +++ b/webpack/JobInvocationDetail/__tests__/OutputCodeBlock.test.js @@ -0,0 +1,69 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { OutputCodeBlock } from '../TemplateInvocationComponents/OutputCodeBlock'; +import { jobInvocationOutput } from './fixtures'; + +const mockShowOutputType = { + stdout: true, + stderr: true, + debug: true, +}; + +describe('OutputCodeBlock', () => { + beforeAll(() => { + Element.prototype.scrollTo = () => {}; + }); + afterAll(() => { + delete Element.prototype.scrollTo; + }); + test('displays the correct output', () => { + render( + + ); + + expect(screen.getByText('3:')).toBeInTheDocument(); + expect(screen.getByText('This is red text')).toHaveStyle('color: red'); + expect(screen.getByText('This is green text')).toHaveStyle( + 'color: lightgreen' + ); + }); + + test('displays no output message when filtered', () => { + render( + + ); + + expect( + screen.getByText('No output for the selected filters') + ).toBeInTheDocument(); + }); + + test('scrolls to top and bottom', async () => { + render( +
+
+ +
+
+ ); + + const scrollToTopButton = screen.getByText('Scroll to top'); + const scrollToBottomButton = screen.getByText('Scroll to bottom'); + + fireEvent.click(scrollToTopButton); + fireEvent.click(scrollToBottomButton); + }); +}); diff --git a/webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js b/webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js new file mode 100644 index 000000000..406361592 --- /dev/null +++ b/webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js @@ -0,0 +1,131 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { Provider } from 'react-redux'; +import { render, screen, act, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import * as APIHooks from 'foremanReact/common/hooks/API/APIHooks'; +import * as api from 'foremanReact/redux/API'; +import { TemplateInvocation } from '../TemplateInvocation'; +import { mockTemplateInvocationResponse } from './fixtures'; + +jest.spyOn(api, 'get'); +jest.mock('foremanReact/common/hooks/API/APIHooks'); +APIHooks.useAPI.mockImplementation(() => ({ + response: mockTemplateInvocationResponse, + status: 'RESOLVED', +})); + +const mockStore = configureMockStore([]); +const store = mockStore({ + HOSTS_API: { + response: { + subtotal: 3, + }, + }, +}); +describe('TemplateInvocation', () => { + test('render', async () => { + render( + + + + ); + + expect(screen.getByText('Target:')).toBeInTheDocument(); + expect(screen.getByText('example-host')).toBeInTheDocument(); + + expect(screen.getByText('This is red text')).toBeInTheDocument(); + expect(screen.getByText('This is default text')).toBeInTheDocument(); + }); + test('filtering toggles', () => { + render( + + + + ); + + act(() => { + fireEvent.click(screen.getByText('STDOUT')); + fireEvent.click(screen.getByText('DEBUG')); + fireEvent.click(screen.getByText('STDERR')); + }); + expect( + screen.queryAllByText('No output for the selected filters') + ).toHaveLength(1); + expect(screen.queryAllByText('Exit status: 1')).toHaveLength(0); + expect( + screen.queryAllByText('StandardError: Job execution failed') + ).toHaveLength(0); + + act(() => { + fireEvent.click(screen.getByText('STDOUT')); + }); + expect( + screen.queryAllByText('No output for the selected filters') + ).toHaveLength(0); + expect(screen.queryAllByText('Exit status: 1')).toHaveLength(1); + expect( + screen.queryAllByText('StandardError: Job execution failed') + ).toHaveLength(0); + + act(() => { + fireEvent.click(screen.getByText('DEBUG')); + }); + expect( + screen.queryAllByText('No output for the selected filters') + ).toHaveLength(0); + expect(screen.queryAllByText('Exit status: 1')).toHaveLength(1); + expect( + screen.queryAllByText('StandardError: Job execution failed') + ).toHaveLength(1); + }); + test('displays an error alert when there is an error', async () => { + APIHooks.useAPI.mockImplementation(() => ({ + response: { response: { data: { error: 'Error message' } } }, + status: 'ERROR', + })); + + render( + + ); + + expect( + screen.getByText( + 'An error occurred while fetching the template invocation details.' + ) + ).toBeInTheDocument(); + expect(screen.getByText('Error message')).toBeInTheDocument(); + }); + + test('displays a skeleton while loading', async () => { + APIHooks.useAPI.mockImplementation(() => ({ + response: {}, + status: 'PENDING', + })); + render( + + ); + + expect(document.querySelectorAll('.pf-c-skeleton')).toHaveLength(1); + }); +}); diff --git a/webpack/JobInvocationDetail/__tests__/fixtures.js b/webpack/JobInvocationDetail/__tests__/fixtures.js index 20cff625d..9a14350d8 100644 --- a/webpack/JobInvocationDetail/__tests__/fixtures.js +++ b/webpack/JobInvocationDetail/__tests__/fixtures.js @@ -144,3 +144,113 @@ export const mockReportTemplatesResponse = { export const mockReportTemplateInputsResponse = { results: [{ id: '34', name: 'job_id' }], }; + +const templateInvocationID = 157; + +export const jobInvocationOutput = [ + { + id: 1958, + template_invocation_id: templateInvocationID, + timestamp: 1733931147.2044532, + meta: null, + external_id: '0', + output_type: 'stdout', + output: + '\u001b[31mThis is red text\u001b[0m\n\u001b[32mThis is green text\u001b[0m\n\u001b[33mThis is yellow text\u001b[0m\n\u001b[34mThis is blue text\u001b[0m\n\u001b[35mThis is magenta text\u001b[0m\n\u001b[36mThis is cyan text\u001b[0m\n\u001b[0mThis is default text\n', + }, + { + output_type: 'stdout', + output: 'Exit status: 6', + timestamp: 1733931142.2044532, + }, + { + output_type: 'stdout', + output: 'Exit status: 5', + timestamp: 1733931143.2044532, + }, + { + output_type: 'stdout', + output: 'Exit status: 4', + timestamp: 1733931144.2044532, + }, + { + output_type: 'stdout', + output: 'Exit status: 3', + timestamp: 1733931145.2044532, + }, + { + output_type: 'stdout', + output: 'Exit status: 2', + timestamp: 1733931146.2044532, + }, + { + output_type: 'stdout', + output: 'Exit status: 1', + timestamp: 1733931147.2044532, + }, + + { + output_type: 'stdout', + output: 'Exit status: 0', + timestamp: 1733931148.2044532, + }, + + { + id: 1907, + template_invocation_id: templateInvocationID, + timestamp: 1718719863.184878, + meta: null, + external_id: '15', + output_type: 'debug', + output: 'StandardError: Job execution failed', + }, + { + id: 1892, + template_invocation_id: templateInvocationID, + timestamp: 1718719857.078763, + meta: null, + external_id: '0', + output_type: 'stderr', + output: + '[DEPRECATION WARNING]: ANSIBLE_CALLBACK_WHITELIST option, normalizing names to \n', + }, +]; + +export const mockTemplateInvocationResponse = { + output: jobInvocationOutput, + preview: { + plain: 'PREVIEW TEXT \n TEST', + }, + input_values: [ + { + id: 40, + template_invocation_id: 157, + template_input_id: 74, + value: + 'echo -e "\\e[31mThis is red text\\e[0m"\necho -e "\\e[32mThis is green text\\e[0m"\necho -e "\\e[33mThis is yellow text\\e[0m"\necho -e "\\e[34mThis is blue text\\e[0m"\necho -e "\\e[35mThis is magenta text\\e[0m"\necho -e "\\e[36mThis is cyan text\\e[0m"\necho -e "\\e[0mThis is default text"', + template_input: { + id: 74, + name: 'command', + required: true, + input_type: 'user', + fact_name: null, + variable_name: null, + puppet_class_name: null, + puppet_parameter_name: null, + description: 'Command to run on the host', + template_id: 189, + created_at: '2024-06-11T10:31:24.084+01:00', + updated_at: '2024-06-11T10:31:24.084+01:00', + options: null, + advanced: false, + value_type: 'plain', + resource_type: null, + default: null, + hidden_value: false, + }, + }, + ], + + job_invocation_description: 'Run tst', + host_name: 'alton-bennette.iris-starley.kari-stadtler.example.net', +}; diff --git a/webpack/Routes/routes.js b/webpack/Routes/routes.js index 3f064d767..202fd3ea5 100644 --- a/webpack/Routes/routes.js +++ b/webpack/Routes/routes.js @@ -2,6 +2,7 @@ import React from 'react'; import JobWizardPage from '../JobWizard'; import JobWizardPageRerun from '../JobWizard/JobWizardPageRerun'; import JobInvocationDetailPage from '../JobInvocationDetail'; +import TemplateInvocationPage from '../JobInvocationDetail/TemplateInvocationPage'; const ForemanREXRoutes = [ { @@ -19,6 +20,11 @@ const ForemanREXRoutes = [ exact: true, render: props => , }, + { + path: '/job_invocations_detail/:jobID/host_invocation/:hostID', + exact: true, + render: props => , + }, ]; export default ForemanREXRoutes;