From 76533c756f7ce62c6475a0db64c06eaadcffe5a7 Mon Sep 17 00:00:00 2001 From: kmalyjur Date: Thu, 26 Sep 2024 13:51:57 +0000 Subject: [PATCH] Fixes #37630 - Display basic list of hosts on the new job_invocations page --- .../api/v2/job_invocations_controller.rb | 48 ++++-- .../api/v2/job_invocations/hosts.json.rabl | 15 ++ config/routes.rb | 1 + .../JobInvocationActions.js | 2 +- .../JobInvocationConstants.js | 84 +++++++++ .../JobInvocationDetail.scss | 1 - .../JobInvocationHostTable.js | 163 ++++++++++++++++++ .../JobInvocationSelectors.js | 4 +- .../__tests__/MainInformation.test.js | 6 +- .../JobInvocationDetail/__tests__/fixtures.js | 9 + webpack/JobInvocationDetail/index.js | 85 +++++---- .../DetailsCard/DefaultLoaderEmptyState.js | 3 +- .../RecentJobsCard/JobStatusIcon.js | 45 ++++- .../components/RecentJobsCard/constants.js | 4 + 14 files changed, 408 insertions(+), 62 deletions(-) create mode 100644 app/views/api/v2/job_invocations/hosts.json.rabl create mode 100644 webpack/JobInvocationDetail/JobInvocationHostTable.js diff --git a/app/controllers/api/v2/job_invocations_controller.rb b/app/controllers/api/v2/job_invocations_controller.rb index a27b9464b..1d1467b6f 100644 --- a/app/controllers/api/v2/job_invocations_controller.rb +++ b/app/controllers/api/v2/job_invocations_controller.rb @@ -3,10 +3,11 @@ module V2 class JobInvocationsController < ::Api::V2::BaseController include ::Api::Version2 include ::Foreman::Renderer + include RemoteExecutionHelper before_action :find_optional_nested_object, :only => %w{output raw_output} before_action :find_host, :only => %w{output raw_output} - before_action :find_resource, :only => %w{show update destroy clone cancel rerun outputs} + before_action :find_resource, :only => %w{show update destroy clone cancel rerun outputs hosts} wrap_parameters JobInvocation, :include => (JobInvocation.attribute_names + [:ssh]) @@ -27,7 +28,14 @@ def show if params[:host_status] == 'true' template_invocations = @template_invocations.includes(:run_host_job_task).to_a - @host_statuses = Hash[template_invocations.map { |ti| [ti.host_id, template_invocation_status(ti)] }] + hosts = @hosts.to_a + @host_statuses = Hash[hosts.map do |host| + template_invocation = template_invocations.find { |ti| ti.host_id == host.id } + task = template_invocation.try(:run_host_job_task) + [host.id, template_invocation_status(task, @job_invocation.task)] + end] + @smart_proxy_id = Hash[template_invocations.map { |ti| [ti.host_id, ti.smart_proxy_id] }] + @smart_proxy_name = Hash[template_invocations.map { |ti| [ti.host_id, ti.smart_proxy_name] }] end end @@ -111,6 +119,29 @@ def output render :json => host_output(@nested_obj, @host, :default => [], :since => params[:since]) end + api :GET, '/job_invocations/:id/hosts', N_('List hosts belonging to job invocation') + param_group :search_and_pagination, ::Api::V2::BaseController + add_scoped_search_description_for(JobInvocation) + param :id, :identifier, :required => true + def hosts + @hosts = @job_invocation.targeting.hosts.authorized(:view_hosts, Host) + @total = @job_invocation.targeting.hosts.size + @template_invocations = @job_invocation.template_invocations + .where(host: @hosts) + .includes(:input_values) + template_invocations = @template_invocations.includes(:run_host_job_task).to_a + hosts = @hosts.to_a + @host_statuses = Hash[hosts.map do |host| + template_invocation = template_invocations.find { |ti| ti.host_id == host.id } + task = template_invocation.try(:run_host_job_task) + [host.id, template_invocation_status(task, @job_invocation.task)] + end] + @smart_proxy_id = Hash[template_invocations.map { |ti| [ti.host_id, ti.smart_proxy_id] }] + @smart_proxy_name = Hash[template_invocations.map { |ti| [ti.host_id, ti.smart_proxy_name] }] + @hosts = @hosts.search_for(params[:search], :order => params[:order]).paginate(:page => params[:page], :per_page => params[:per_page]) + render :hosts, :layout => 'api/v2/layouts/index_layout' + end + api :GET, '/job_invocations/:id/hosts/:host_id/raw', N_('Get raw output for a host') param :id, :identifier, :required => true param :host_id, :identifier, :required => true @@ -187,7 +218,7 @@ def allowed_nested_id def action_permission case params[:action] - when 'output', 'raw_output', 'outputs' + when 'output', 'raw_output', 'outputs', 'hosts' :view when 'cancel' :cancel @@ -255,17 +286,6 @@ def delayed_task_output(task, default: nil) def parent_scope resource_class.where(nil) end - - def template_invocation_status(template_invocation) - task = template_invocation.try(:run_host_job_task) - parent_task = @job_invocation.task - - return(parent_task.result == 'cancelled' ? 'cancelled' : 'N/A') if task.nil? - return task.state if task.state == 'running' || task.state == 'planned' - return 'error' if task.result == 'warning' - - task.result - end 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 new file mode 100644 index 000000000..0467f9ce5 --- /dev/null +++ b/app/views/api/v2/job_invocations/hosts.json.rabl @@ -0,0 +1,15 @@ +collection @hosts + +attribute :name, :operatingsystem_id, :operatingsystem_name, :hostgroup_id, :hostgroup_name + +node :job_status do |host| + @host_statuses[host.id] +end + +node :smart_proxy_id do |host| + @smart_proxy_id[host.id] +end + +node :smart_proxy_name do |host| + @smart_proxy_name[host.id] +end diff --git a/config/routes.rb b/config/routes.rb index 51d97f21b..3cf6759ac 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -63,6 +63,7 @@ get '/raw', :to => 'job_invocations#raw_output' end member do + get 'hosts' post 'cancel' post 'rerun' get 'template_invocations', :to => 'template_invocations#template_invocations' diff --git a/webpack/JobInvocationDetail/JobInvocationActions.js b/webpack/JobInvocationDetail/JobInvocationActions.js index a70b80e6b..f80425065 100644 --- a/webpack/JobInvocationDetail/JobInvocationActions.js +++ b/webpack/JobInvocationDetail/JobInvocationActions.js @@ -15,7 +15,7 @@ import { UPDATE_JOB, } from './JobInvocationConstants'; -export const getData = url => dispatch => { +export const getJobInvocation = url => dispatch => { const fetchData = withInterval( get({ key: JOB_INVOCATION_KEY, diff --git a/webpack/JobInvocationDetail/JobInvocationConstants.js b/webpack/JobInvocationDetail/JobInvocationConstants.js index 2673d3aaf..a67f916a9 100644 --- a/webpack/JobInvocationDetail/JobInvocationConstants.js +++ b/webpack/JobInvocationDetail/JobInvocationConstants.js @@ -1,14 +1,21 @@ +/* eslint-disable camelcase */ +import React from 'react'; import { foremanUrl } from 'foremanReact/common/helpers'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { useForemanHostDetailsPageUrl } from 'foremanReact/Root/Context/ForemanContext'; +import JobStatusIcon from '../react_app/components/RecentJobsCard/JobStatusIcon'; 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 GET_TEMPLATE_INVOCATIONS = 'GET_TEMPLATE_INVOCATIONS'; 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 JOB_INVOCATION_HOSTS = 'JOB_INVOCATION_HOSTS'; export const currentPermissionsUrl = foremanUrl( '/api/v2/permissions/current_permissions' ); @@ -20,6 +27,12 @@ export const STATUS = { CANCELLED: 'cancelled', }; +export const STATUS_UPPERCASE = { + RESOLVED: 'RESOLVED', + ERROR: 'ERROR', + PENDING: 'PENDING', +}; + export const DATE_OPTIONS = { day: 'numeric', month: 'short', @@ -29,3 +42,74 @@ export const DATE_OPTIONS = { hour12: false, timeZoneName: 'short', }; + +const Columns = () => { + const getColumnsStatus = ({ hostJobStatus }) => { + switch (hostJobStatus) { + case 'success': + return { title: __('Succeeded'), status: 0 }; + case 'error': + return { title: __('Failed'), status: 1 }; + case 'planned': + return { title: __('Scheduled'), status: 2 }; + case 'running': + return { title: __('Pending'), status: 3 }; + case 'cancelled': + return { title: __('Cancelled'), status: 4 }; + case 'Awaiting start': + return { title: __('Awaiting start'), status: 5 }; + default: + return { title: hostJobStatus, status: 6 }; + } + }; + const hostDetailsPageUrl = useForemanHostDetailsPageUrl(); + + return { + name: { + title: __('Name'), + wrapper: ({ name }) => ( + {name} + ), + weight: 1, + }, + groups: { + title: __('Host group'), + wrapper: ({ hostgroup_id, hostgroup_name }) => ( + {hostgroup_name} + ), + weight: 2, + }, + os: { + title: __('OS'), + wrapper: ({ operatingsystem_id, operatingsystem_name }) => ( + + {operatingsystem_name} + + ), + weight: 3, + }, + smart_proxy: { + title: __('Smart proxy'), + wrapper: ({ smart_proxy_name, smart_proxy_id }) => ( + {smart_proxy_name} + ), + weight: 4, + }, + status: { + title: __('Status'), + wrapper: ({ job_status }) => { + const { title, status } = getColumnsStatus({ + hostJobStatus: job_status, + }); + return ( + + {title || __('Unknown')} + + ); + }, + weight: 5, + }, + }; +}; + +export default Columns; diff --git a/webpack/JobInvocationDetail/JobInvocationDetail.scss b/webpack/JobInvocationDetail/JobInvocationDetail.scss index 9482277a0..6cc58986e 100644 --- a/webpack/JobInvocationDetail/JobInvocationDetail.scss +++ b/webpack/JobInvocationDetail/JobInvocationDetail.scss @@ -38,4 +38,3 @@ height: $chart_size; } } - \ No newline at end of file diff --git a/webpack/JobInvocationDetail/JobInvocationHostTable.js b/webpack/JobInvocationDetail/JobInvocationHostTable.js new file mode 100644 index 000000000..e3b6f1a1f --- /dev/null +++ b/webpack/JobInvocationDetail/JobInvocationHostTable.js @@ -0,0 +1,163 @@ +/* eslint-disable camelcase */ +import PropTypes from 'prop-types'; +import React, { useMemo } from 'react'; +import { Icon } from 'patternfly-react'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { Tr, Td } from '@patternfly/react-table'; +import { + Title, + EmptyState, + EmptyStateVariant, + EmptyStateBody, +} from '@patternfly/react-core'; +import { foremanUrl } from 'foremanReact/common/helpers'; +import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; +import { Table } from 'foremanReact/components/PF4/TableIndexPage/Table/Table'; +import TableIndexPage from 'foremanReact/components/PF4/TableIndexPage/TableIndexPage'; +import { useSetParamsAndApiAndSearch } from 'foremanReact/components/PF4/TableIndexPage/Table/TableIndexHooks'; +import { + useBulkSelect, + useUrlParams, +} from 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks'; +import { getControllerSearchProps } from 'foremanReact/constants'; +import Columns, { + JOB_INVOCATION_HOSTS, + STATUS_UPPERCASE, +} from './JobInvocationConstants'; + +const JobInvocationHostTable = ({ id, targeting }) => { + const columns = Columns(); + const columnNamesKeys = Object.keys(columns); + const apiOptions = { key: JOB_INVOCATION_HOSTS, search: urlSearchQuery }; + const { + search: urlSearchQuery = '', + page: urlPage, + per_page: urlPerPage, + } = useUrlParams(); + const defaultParams = { search: urlSearchQuery }; + if (urlPage) defaultParams.page = Number(urlPage); + if (urlPerPage) defaultParams.per_page = Number(urlPerPage); + const { response, status, setAPIOptions } = useAPI( + 'get', + `/api/job_invocations/${id}/hosts`, + { + params: { ...defaultParams, key: JOB_INVOCATION_HOSTS }, + } + ); + + const { setParamsAndAPI, params } = useSetParamsAndApiAndSearch({ + defaultParams, + apiOptions, + setAPIOptions, + }); + + const combinedResponse = { + response: { + search: '', + can_create: false, + results: response?.results || [], + total: response?.total || 0, + per_page: response?.perPage, + page: response?.page, + subtotal: response?.subtotal || 0, + message: response?.message || 'error', + }, + status, + setAPIOptions, + }; + + const { updateSearchQuery } = useBulkSelect({ + initialSearchQuery: urlSearchQuery, + }); + + const controller = 'hosts'; + const memoDefaultSearchProps = useMemo( + () => getControllerSearchProps(controller), + [controller] + ); + memoDefaultSearchProps.autocomplete.url = foremanUrl( + `/${controller}/auto_complete_search` + ); + + const customEmptyState = ( + + + + + + + + {__('No Results')} + + +
+ {targeting?.targeting_type === 'dynamic_query' ? ( + <> + {__('The dynamic query is still being processed. You can ')} + + {__('view the hosts')} + + {__(' targeted by the query.')} + + ) : ( + __('No hosts found') + )} +
+
+
+ + + ); + + return ( + + {}} + errorMessage={ + status === STATUS_UPPERCASE.ERROR && response?.message + ? response.message + : null + } + isPending={status === STATUS_UPPERCASE.PENDING} + isDeleteable={false} + > + {response?.results?.map((result, rowIndex) => ( + + {columnNamesKeys.map(k => ( + + ))} + + ))} +
{columns[k].wrapper(result)}
+
+ ); +}; + +JobInvocationHostTable.propTypes = { + id: PropTypes.string.isRequired, + targeting: PropTypes.object.isRequired, +}; + +JobInvocationHostTable.defaultProps = {}; + +export default JobInvocationHostTable; diff --git a/webpack/JobInvocationDetail/JobInvocationSelectors.js b/webpack/JobInvocationDetail/JobInvocationSelectors.js index cd2eb57ca..f5ba99d66 100644 --- a/webpack/JobInvocationDetail/JobInvocationSelectors.js +++ b/webpack/JobInvocationDetail/JobInvocationSelectors.js @@ -1,10 +1,10 @@ import { selectAPIResponse } from 'foremanReact/redux/API/APISelectors'; -import { JOB_INVOCATION_KEY } from './JobInvocationConstants'; +import { JOB_INVOCATION_KEY, GET_TASK } from './JobInvocationConstants'; export const selectItems = state => selectAPIResponse(state, JOB_INVOCATION_KEY); -export const selectTask = state => selectAPIResponse(state, 'GET_TASK'); +export const selectTask = state => selectAPIResponse(state, GET_TASK); export const selectTaskCancelable = state => selectTask(state).available_actions?.cancellable || false; diff --git a/webpack/JobInvocationDetail/__tests__/MainInformation.test.js b/webpack/JobInvocationDetail/__tests__/MainInformation.test.js index 83d6e045c..bfcc4d158 100644 --- a/webpack/JobInvocationDetail/__tests__/MainInformation.test.js +++ b/webpack/JobInvocationDetail/__tests__/MainInformation.test.js @@ -76,6 +76,10 @@ api.get.mockImplementation(({ handleSuccess, ...action }) => { return { type: 'get', ...action }; }); +jest.mock('../JobInvocationHostTable.js', () => () => ( +
Mock Table
+)); + const reportTemplateJobId = mockReportTemplatesResponse.results[0].id; const mockStore = configureMockStore([thunk]); @@ -207,7 +211,7 @@ describe('JobInvocationDetailPage', () => { { key: GET_REPORT_TEMPLATES, url: '/api/report_templates' }, { key: JOB_INVOCATION_KEY, - url: `/api/job_invocations/${jobId}`, + url: `/api/job_invocations/${jobId}?host_status=true`, }, { key: GET_REPORT_TEMPLATE_INPUTS, diff --git a/webpack/JobInvocationDetail/__tests__/fixtures.js b/webpack/JobInvocationDetail/__tests__/fixtures.js index c4f9e7846..d984a84f0 100644 --- a/webpack/JobInvocationDetail/__tests__/fixtures.js +++ b/webpack/JobInvocationDetail/__tests__/fixtures.js @@ -1,4 +1,7 @@ export const jobInvocationData = { + search: '', + per_page: 20, + page: 1, id: 123, description: 'Description', job_category: 'Commands', @@ -40,6 +43,9 @@ export const jobInvocationData = { }; export const jobInvocationDataScheduled = { + search: '', + per_page: 20, + page: 1, id: 456, description: 'Description', job_category: 'Commands', @@ -62,6 +68,9 @@ export const jobInvocationDataScheduled = { }; export const jobInvocationDataRecurring = { + search: '', + per_page: 20, + page: 1, id: 789, description: 'Description', job_category: 'Commands', diff --git a/webpack/JobInvocationDetail/index.js b/webpack/JobInvocationDetail/index.js index 1605a46ae..43816ccde 100644 --- a/webpack/JobInvocationDetail/index.js +++ b/webpack/JobInvocationDetail/index.js @@ -1,12 +1,17 @@ import PropTypes from 'prop-types'; import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { Divider, Flex } from '@patternfly/react-core'; +import { + Divider, + Flex, + PageSection, + PageSectionVariants, +} 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, getTask } from './JobInvocationActions'; +import { getJobInvocation, getTask } from './JobInvocationActions'; import { CURRENT_PERMISSIONS, DATE_OPTIONS, @@ -19,6 +24,7 @@ import JobInvocationOverview from './JobInvocationOverview'; import { selectItems } from './JobInvocationSelectors'; import JobInvocationSystemStatusChart from './JobInvocationSystemStatusChart'; import JobInvocationToolbarButtons from './JobInvocationToolbarButtons'; +import JobInvocationHostTable from './JobInvocationHostTable'; const JobInvocationDetailPage = ({ match: { @@ -32,6 +38,7 @@ const JobInvocationDetailPage = ({ status_label: statusLabel, task, start_at: startAt, + targeting, } = items; const finished = statusLabel === STATUS.FAILED || @@ -57,7 +64,7 @@ const JobInvocationDetailPage = ({ } useEffect(() => { - dispatch(getData(`/api/job_invocations/${id}`)); + dispatch(getJobInvocation(`/api/job_invocations/${id}?host_status=true`)); if (finished && !autoRefresh) { dispatch(stopInterval(JOB_INVOCATION_KEY)); } @@ -82,45 +89,55 @@ const JobInvocationDetailPage = ({ }; return ( - - } - searchable={false} - > - + + } + searchable={false} > - - - + + + + - - + + + {items.id !== undefined && ( + + )} + + ); }; diff --git a/webpack/__mocks__/foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState.js b/webpack/__mocks__/foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState.js index a8f9748d1..533d49bd7 100644 --- a/webpack/__mocks__/foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState.js +++ b/webpack/__mocks__/foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState.js @@ -1,8 +1,7 @@ import React from 'react'; -import { translate as __ } from '../../../common/I18n'; const DefaultLoaderEmptyState = () => ( - {__('Not available')} + Not available ); export default DefaultLoaderEmptyState; diff --git a/webpack/react_app/components/RecentJobsCard/JobStatusIcon.js b/webpack/react_app/components/RecentJobsCard/JobStatusIcon.js index 3fb1d3493..a5b819d67 100644 --- a/webpack/react_app/components/RecentJobsCard/JobStatusIcon.js +++ b/webpack/react_app/components/RecentJobsCard/JobStatusIcon.js @@ -3,29 +3,60 @@ import PropTypes from 'prop-types'; import { CheckCircleIcon, ExclamationCircleIcon, + BuildIcon, + RunningIcon, + ExclamationTriangleIcon, QuestionCircleIcon, } from '@patternfly/react-icons'; -import { JOB_SUCCESS_STATUS, JOB_ERROR_STATUS } from './constants'; +import { + JOB_SUCCESS_STATUS, + JOB_ERROR_STATUS, + JOB_PLANNED_STATUS, + JOB_RUNNING_STATUS, + JOB_CANCELLED_STATUS, + JOB_AWAITING_STATUS, +} from './constants'; import './styles.scss'; const JobStatusIcon = ({ status, children, ...props }) => { switch (status) { case JOB_SUCCESS_STATUS: return ( - - {children} + + {children} ); case JOB_ERROR_STATUS: return ( - - {children} + + {children} + + ); + case JOB_PLANNED_STATUS: + return ( + + {children} + + ); + case JOB_RUNNING_STATUS: + return ( + + {children} + + ); + case JOB_CANCELLED_STATUS: + return ( + + {' '} + {children} ); + case JOB_AWAITING_STATUS: + return {children}; default: return ( - - {children} + + {children} ); } diff --git a/webpack/react_app/components/RecentJobsCard/constants.js b/webpack/react_app/components/RecentJobsCard/constants.js index bd298c706..a5b9cde50 100644 --- a/webpack/react_app/components/RecentJobsCard/constants.js +++ b/webpack/react_app/components/RecentJobsCard/constants.js @@ -5,6 +5,10 @@ export const SCHEDULED_TAB = 2; export const JOB_SUCCESS_STATUS = 0; export const JOB_ERROR_STATUS = 1; +export const JOB_PLANNED_STATUS = 2; +export const JOB_RUNNING_STATUS = 3; +export const JOB_CANCELLED_STATUS = 4; +export const JOB_AWAITING_STATUS = 5; export const JOB_BASE_URL = '/job_invocations?search=targeted_host_id+%3D+'; export const JOB_API_URL =