From a86724a3063cd3e33a0d4c1684badbdd284091a5 Mon Sep 17 00:00:00 2001 From: kmalyjur Date: Thu, 4 Jul 2024 17:17:36 +0000 Subject: [PATCH] Fixes #37630 - Display basic list of hosts on the new job_invocations detail page --- .../api/v2/job_invocations_controller.rb | 29 +++- .../api/v2/job_invocations/hosts.json.rabl | 15 ++ .../v2/template_invocations/base.json.rabl | 6 + config/routes.rb | 1 + package.json | 6 +- .../JobInvocationActions.js | 2 +- .../JobInvocationConstants.js | 53 +++++++ .../JobInvocationDetail.scss | 4 + .../JobInvocationHostTable.js | 132 ++++++++++++++++++ .../JobInvocationSelectors.js | 4 +- webpack/JobInvocationDetail/index.js | 6 +- .../DetailsCard/DefaultLoaderEmptyState.js | 6 +- 12 files changed, 250 insertions(+), 14 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..972167a31 100644 --- a/app/controllers/api/v2/job_invocations_controller.rb +++ b/app/controllers/api/v2/job_invocations_controller.rb @@ -6,7 +6,7 @@ class JobInvocationsController < ::Api::V2::BaseController 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]) @@ -111,6 +111,31 @@ 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 :include, ['parameters', 'all_parameters'], :desc => N_("Array of extra information types to include") + param_group :search_and_pagination, ::Api::V2::BaseController + add_scoped_search_description_for(JobInvocation) + param :id, :identifier, :required => true + def hosts + Rails.logger.info "Params: #{params.inspect}" + @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 + @host_statuses = Hash[template_invocations.map { |ti| [ti.host_id, template_invocation_status(ti)] }] + @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] }] + + if params[:include].present? + @parameters = params[:include].include?('parameters') + @all_parameters = params[:include].include?('all_parameters') + end + @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 +212,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 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/app/views/api/v2/template_invocations/base.json.rabl b/app/views/api/v2/template_invocations/base.json.rabl index 4d03de2c3..cc8d51d4a 100644 --- a/app/views/api/v2/template_invocations/base.json.rabl +++ b/app/views/api/v2/template_invocations/base.json.rabl @@ -3,3 +3,9 @@ object @template_invocation attributes :id, :template_id, :job_invocation_id, :effective_user, :host_id, :run_host_job_task_id node(:host_name) { |ti| ti.host.name } +node(:smart_proxy) do |ti| +{ + id: ti.smart_proxy&.id, + name: ti.smart_proxy&.name +} +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/package.json b/package.json index e0e0dcf6c..229117baa 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,13 @@ "@theforeman/test": ">= 12.0.1", "@theforeman/vendor-dev": ">= 12.0.1", "babel-eslint": "^10.0.0", + "concurrently": "^8.2.2", "eslint": "^6.8.0", + "graphql": "^15.5.0", + "graphql-tag": "^2.11.0", + "http-server": "^14.1.1", "prettier": "^1.19.1", "redux-mock-store": "^1.2.2", - "graphql-tag": "^2.11.0", - "graphql": "^15.5.0", "victory-core": "~36.8.6" }, "peerDependencies": { 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..fef6d4934 100644 --- a/webpack/JobInvocationDetail/JobInvocationConstants.js +++ b/webpack/JobInvocationDetail/JobInvocationConstants.js @@ -1,14 +1,19 @@ +/* eslint-disable camelcase */ +import React from 'react'; import { foremanUrl } from 'foremanReact/common/helpers'; +import { translate as __ } from 'foremanReact/common/I18n'; 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' ); @@ -29,3 +34,51 @@ export const DATE_OPTIONS = { hour12: false, timeZoneName: 'short', }; + +const getColumnsStatus = name => { + switch (name) { + case 'success': + return { title: __('Succeeded'), status: 0 }; + case 'error': + return { title: __('Failed'), status: 1 }; + default: + return { title: __('Unknown'), status: 2 }; + } +}; + +export const columns = { + 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'), + tableTitle: job_status => getColumnsStatus(job_status).title, + status: job_status => getColumnsStatus(job_status).status, + weight: 5, + }, +}; diff --git a/webpack/JobInvocationDetail/JobInvocationDetail.scss b/webpack/JobInvocationDetail/JobInvocationDetail.scss index 9482277a0..03ca16582 100644 --- a/webpack/JobInvocationDetail/JobInvocationDetail.scss +++ b/webpack/JobInvocationDetail/JobInvocationDetail.scss @@ -38,4 +38,8 @@ height: $chart_size; } } + +.job-invocation-host-table-page-section { + padding-top: 0px; +} \ No newline at end of file diff --git a/webpack/JobInvocationDetail/JobInvocationHostTable.js b/webpack/JobInvocationDetail/JobInvocationHostTable.js new file mode 100644 index 000000000..5f4ee7530 --- /dev/null +++ b/webpack/JobInvocationDetail/JobInvocationHostTable.js @@ -0,0 +1,132 @@ +import PropTypes from 'prop-types'; +import React, { useMemo } from 'react'; +import { Tr, Td } from '@patternfly/react-table'; +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 JobStatusIcon from '../react_app/components/RecentJobsCard/JobStatusIcon'; +import { + columns, + JOB_INVOCATION_HOSTS, + STATUS, +} from './JobInvocationConstants'; + +const JobInvocationHostTable = ({ data }) => { + const { id, task } = data; + const columnNamesKeys = Object.keys(columns); + const apiOptions = { key: JOB_INVOCATION_HOSTS, search: urlSearchQuery }; + const { + searchParam: 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 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: task?.state, + setAPIOptions, + }; + + const { setParamsAndAPI, params } = useSetParamsAndApiAndSearch({ + defaultParams, + apiOptions, + setAPIOptions, + }); + + const { updateSearchQuery } = useBulkSelect({ + results: response?.results || [], + metadata: { + total: response?.total || 0, + page: response?.page, + selectable: response?.subtotal || 0, + }, + initialSearchQuery: urlSearchQuery, + }); + + const controller = 'hosts'; + const memoDefaultSearchProps = useMemo( + () => getControllerSearchProps(controller), + [controller] + ); + memoDefaultSearchProps.autocomplete.url = foremanUrl( + `/${controller}/auto_complete_search` + ); + + return ( + + {}} + errorMessage={ + status === STATUS.ERROR && response?.message ? response.message : null + } + isPending={task?.state === STATUS.PENDING} + isDeleteable={false} + > + {response?.results?.map((result, rowIndex) => ( + + {columnNamesKeys.map(k => ( + + ))} + + ))} +
+ {k === 'status' ? ( + + {columns[k].tableTitle(result.job_status)} + + ) : ( + columns[k].wrapper(result) + )} +
+
+ ); +}; + +JobInvocationHostTable.propTypes = { + data: 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/index.js b/webpack/JobInvocationDetail/index.js index 1605a46ae..62fc5bb38 100644 --- a/webpack/JobInvocationDetail/index.js +++ b/webpack/JobInvocationDetail/index.js @@ -6,7 +6,7 @@ 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 +19,7 @@ import JobInvocationOverview from './JobInvocationOverview'; import { selectItems } from './JobInvocationSelectors'; import JobInvocationSystemStatusChart from './JobInvocationSystemStatusChart'; import JobInvocationToolbarButtons from './JobInvocationToolbarButtons'; +import JobInvocationHostTable from './JobInvocationHostTable'; const JobInvocationDetailPage = ({ match: { @@ -57,7 +58,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)); } @@ -120,6 +121,7 @@ const JobInvocationDetailPage = ({ /> + {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..b8589cbdb 100644 --- a/webpack/__mocks__/foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState.js +++ b/webpack/__mocks__/foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState.js @@ -1,8 +1,4 @@ import React from 'react'; -import { translate as __ } from '../../../common/I18n'; - -const DefaultLoaderEmptyState = () => ( - {__('Not available')} -); +const DefaultLoaderEmptyState = () =>
; export default DefaultLoaderEmptyState;