diff --git a/app/controllers/api/v2/job_invocations_controller.rb b/app/controllers/api/v2/job_invocations_controller.rb
index 1fb898870..0c5dd04b4 100644
--- a/app/controllers/api/v2/job_invocations_controller.rb
+++ b/app/controllers/api/v2/job_invocations_controller.rb
@@ -270,6 +270,11 @@ def parent_scope
def set_hosts_and_template_invocations
@pattern_template_invocations = @job_invocation.pattern_template_invocations.includes(:input_values)
@hosts = @job_invocation.targeting.hosts.authorized(:view_hosts, Host)
+
+ unless params[:search].nil?
+ @hosts = @hosts.joins(:template_invocations)
+ .where(:template_invocations => { :job_invocation_id => @job_invocation.id})
+ end
@template_invocations = @job_invocation.template_invocations
.where(host: @hosts)
.includes(:input_values)
diff --git a/webpack/JobInvocationDetail/JobInvocationConstants.js b/webpack/JobInvocationDetail/JobInvocationConstants.js
index a99bad188..1c997b89d 100644
--- a/webpack/JobInvocationDetail/JobInvocationConstants.js
+++ b/webpack/JobInvocationDetail/JobInvocationConstants.js
@@ -42,6 +42,14 @@ export const STATUS_UPPERCASE = {
PENDING: 'PENDING',
};
+export const STATUS_TITLES = {
+ ALL_STATUSES: { id: 'all_statuses', title: __('All statuses') },
+ SUCCESS: { id: 'success', title: __('Succeeded') },
+ FAILED: { id: 'failed', title: __('Failed') },
+ PENDING: { id: 'pending', title: __('In Progress') },
+ CANCELLED: { id: 'cancelled', title: __('Cancelled') },
+};
+
export const DATE_OPTIONS = {
day: 'numeric',
month: 'short',
@@ -61,7 +69,7 @@ const Columns = () => {
return { title: __('Failed'), status: 1 };
case 'planned':
return { title: __('Scheduled'), status: 2 };
- case 'running':
+ case 'running' || 'pending':
return { title: __('Pending'), status: 3 };
case 'cancelled':
return { title: __('Cancelled'), status: 4 };
@@ -84,13 +92,15 @@ const Columns = () => {
wrapper: ({ name }) => (
{name}
),
+ isSorted: true,
weight: 1,
},
- groups: {
+ hostgroup: {
title: __('Host group'),
wrapper: ({ hostgroup_id, hostgroup_name }) => (
{hostgroup_name}
),
+ isSorted: true,
weight: 2,
},
os: {
@@ -100,6 +110,7 @@ const Columns = () => {
{operatingsystem_name}
),
+ isSorted: true,
weight: 3,
},
smart_proxy: {
@@ -107,6 +118,7 @@ const Columns = () => {
wrapper: ({ smart_proxy_name, smart_proxy_id }) => (
{smart_proxy_name}
),
+ isSorted: true,
weight: 4,
},
status: {
diff --git a/webpack/JobInvocationDetail/JobInvocationHostTable.js b/webpack/JobInvocationDetail/JobInvocationHostTable.js
index 89dd9e548..dd3cbe8b0 100644
--- a/webpack/JobInvocationDetail/JobInvocationHostTable.js
+++ b/webpack/JobInvocationDetail/JobInvocationHostTable.js
@@ -20,7 +20,6 @@ import {
useBulkSelect,
useUrlParams,
} from 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks';
-import Pagination from 'foremanReact/components/Pagination';
import { getControllerSearchProps } from 'foremanReact/constants';
import Columns, {
JOB_INVOCATION_HOSTS,
@@ -29,17 +28,41 @@ import Columns, {
import { TemplateInvocation } from './TemplateInvocation';
import { OpenAlInvocations, PopupAlert } from './OpenAlInvocations';
import { RowActions } from './TemplateInvocationComponents/TemplateActionButtons';
+import JobInvocationHostTableToolbar from './JobInvocationHostTableToolbar';
-const JobInvocationHostTable = ({ id, targeting, finished, autoRefresh }) => {
+const JobInvocationHostTable = ({
+ id,
+ targeting,
+ finished,
+ autoRefresh,
+ initialFilter,
+}) => {
const columns = Columns();
const columnNamesKeys = Object.keys(columns);
const apiOptions = { key: JOB_INVOCATION_HOSTS };
+ const [selectedFilter, setSelectedFilter] = useState(initialFilter || '');
const {
searchParam: urlSearchQuery = '',
page: urlPage,
per_page: urlPerPage,
} = useUrlParams();
- const defaultParams = { search: urlSearchQuery };
+ const constructFilter = (
+ filter = selectedFilter,
+ search = urlSearchQuery
+ ) => {
+ const dropdownFilterClause =
+ filter && filter !== 'all_statuses'
+ ? `job_invocation.result = ${filter}`
+ : null;
+ const parts = [dropdownFilterClause, search];
+ return parts
+ .filter(x => x)
+ .map(fragment => `(${fragment})`)
+ .join(' AND ');
+ };
+
+ const search = constructFilter();
+ const defaultParams = search !== '' ? { search } : {};
if (urlPage) defaultParams.page = Number(urlPage);
if (urlPerPage) defaultParams.per_page = Number(urlPerPage);
const [expandedHost, setExpandedHost] = useState([]);
@@ -47,40 +70,22 @@ const JobInvocationHostTable = ({ id, targeting, finished, autoRefresh }) => {
'get',
`/api/job_invocations/${id}/hosts`,
{
- params: {
- ...defaultParams,
- },
- key: JOB_INVOCATION_HOSTS,
- handleSuccess: ({ data }) => {
- if (data?.results?.length === 1) setExpandedHost([data.results[0].id]);
- },
+ params: defaultParams,
}
);
- const combinedResponse = {
- response: {
- search: urlSearchQuery,
- 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 { setParamsAndAPI, params } = useSetParamsAndApiAndSearch({
+ const { params } = useSetParamsAndApiAndSearch({
defaultParams,
apiOptions,
- setAPIOptions: combinedResponse.setAPIOptions,
+ setAPIOptions,
});
- const { updateSearchQuery } = useBulkSelect({
+ const { updateSearchQuery: updateSearchQueryBulk } = useBulkSelect({
initialSearchQuery: urlSearchQuery,
});
+ const updateSearchQuery = searchQuery => {
+ updateSearchQueryBulk(searchQuery);
+ };
const controller = 'hosts';
const memoDefaultSearchProps = useMemo(
@@ -91,6 +96,23 @@ const JobInvocationHostTable = ({ id, targeting, finished, autoRefresh }) => {
`/${controller}/auto_complete_search`
);
+ const wrapSetSelectedFilter = filter => {
+ const filterSearch = constructFilter(filter);
+ setAPIOptions(prevOptions => {
+ if (prevOptions.params.search !== filterSearch) {
+ return {
+ ...prevOptions,
+ params: {
+ ...prevOptions.params,
+ search: filterSearch,
+ },
+ };
+ }
+ return prevOptions;
+ });
+ setSelectedFilter(filter);
+ };
+
useEffect(() => {
const intervalId = setInterval(() => {
if (!finished || autoRefresh) {
@@ -108,24 +130,31 @@ const JobInvocationHostTable = ({ id, targeting, finished, autoRefresh }) => {
};
}, [finished, autoRefresh, setAPIOptions]);
- const onPagination = newPagination => {
- setParamsAndAPI({
- ...params,
- ...newPagination,
- search: urlSearchQuery,
- });
+ const wrapSetAPIOptions = newAPIOptions => {
+ setAPIOptions(prevOptions => ({
+ ...prevOptions,
+ params: {
+ ...prevOptions.params,
+ ...newAPIOptions.params,
+ search: constructFilter(undefined, newAPIOptions?.params?.search),
+ },
+ }));
};
- const bottomPagination = (
-
- );
+ const combinedResponse = {
+ response: {
+ search: urlSearchQuery,
+ 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: wrapSetAPIOptions,
+ };
const customEmptyState = (
@@ -183,26 +212,30 @@ const JobInvocationHostTable = ({ id, targeting, finished, autoRefresh }) => {
creatable={false}
replacementResponse={combinedResponse}
updateSearchQuery={updateSearchQuery}
- customToolbarItems={
+ customToolbarItems={[
- }
+ />,
+ ,
+ ]}
>
{}}
errorMessage={
@@ -212,7 +245,6 @@ const JobInvocationHostTable = ({ id, targeting, finished, autoRefresh }) => {
}
isPending={status === STATUS_UPPERCASE.PENDING}
isDeleteable={false}
- bottomPagination={bottomPagination}
childrenOutsideTbody
>
{results?.map((result, rowIndex) => (
@@ -267,6 +299,7 @@ JobInvocationHostTable.propTypes = {
targeting: PropTypes.object.isRequired,
finished: PropTypes.bool.isRequired,
autoRefresh: PropTypes.bool.isRequired,
+ initialFilter: PropTypes.string.isRequired,
};
JobInvocationHostTable.defaultProps = {};
diff --git a/webpack/JobInvocationDetail/JobInvocationHostTableToolbar.js b/webpack/JobInvocationDetail/JobInvocationHostTableToolbar.js
new file mode 100644
index 000000000..8b3601c2c
--- /dev/null
+++ b/webpack/JobInvocationDetail/JobInvocationHostTableToolbar.js
@@ -0,0 +1,63 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { translate as __ } from 'foremanReact/common/I18n';
+import { Select, SelectOption, SelectList } from '@patternfly/react-core/next'; // remove "/next" after switching to PF5
+import { MenuToggle, ToolbarItem } from '@patternfly/react-core';
+import { STATUS_TITLES } from './JobInvocationConstants';
+
+const JobInvocationHostTableToolbar = ({
+ dropdownFilter,
+ setDropdownFilter,
+}) => {
+ const [isOpen, setIsOpen] = React.useState(false);
+ const onSelect = (_event, itemId) => {
+ setDropdownFilter(itemId);
+ setIsOpen(false);
+ };
+
+ const toggle = toggleRef => (
+ setIsOpen(!isOpen)}
+ isExpanded={isOpen}
+ style={{
+ width: '200px',
+ }}
+ >
+ {Object.values(STATUS_TITLES).find(status => status.id === dropdownFilter)
+ ?.title || __('All statuses')}
+
+ );
+
+ return (
+
+
+
+ );
+};
+
+JobInvocationHostTableToolbar.propTypes = {
+ dropdownFilter: PropTypes.string.isRequired,
+ setDropdownFilter: PropTypes.func.isRequired,
+};
+
+export default JobInvocationHostTableToolbar;
diff --git a/webpack/JobInvocationDetail/JobInvocationSystemStatusChart.js b/webpack/JobInvocationDetail/JobInvocationSystemStatusChart.js
index 0497fc5a5..e65e42078 100644
--- a/webpack/JobInvocationDetail/JobInvocationSystemStatusChart.js
+++ b/webpack/JobInvocationDetail/JobInvocationSystemStatusChart.js
@@ -17,18 +17,20 @@ import {
Text,
} from '@patternfly/react-core';
import {
- global_palette_black_600 as canceledColor,
+ global_palette_black_600 as cancelledColor,
global_palette_black_500 as emptyChartDonut,
global_palette_red_100 as failedColor,
global_palette_blue_300 as inProgressColor,
global_palette_green_500 as successedColor,
} from '@patternfly/react-tokens';
+import { STATUS_TITLES } from './JobInvocationConstants';
import './JobInvocationDetail.scss';
const JobInvocationSystemStatusChart = ({
data,
isAlreadyStarted,
formattedStartDate,
+ onFilterChange,
}) => {
const {
succeeded,
@@ -42,7 +44,7 @@ const JobInvocationSystemStatusChart = ({
{ title: __('Succeeded:'), count: succeeded, color: successedColor.value },
{ title: __('Failed:'), count: failed, color: failedColor.value },
{ title: __('In Progress:'), count: pending, color: inProgressColor.value },
- { title: __('Canceled:'), count: cancelled, color: canceledColor.value },
+ { title: __('Cancelled:'), count: cancelled, color: cancelledColor.value },
];
const chartDonutTitle = () => {
if (total > 0) return `${succeeded.toString()}/${total}`;
@@ -51,6 +53,7 @@ const JobInvocationSystemStatusChart = ({
};
const chartSize = 105;
const [legendWidth, setLegendWidth] = useState(270);
+ const [cursor, setCursor] = useState('default');
// Calculates chart legend width based on its content
useEffect(() => {
@@ -64,9 +67,19 @@ const JobInvocationSystemStatusChart = ({
}
}, [isAlreadyStarted, data]);
+ const onChartClick = (_evt, { index }) => {
+ const statusKeys = Object.keys(STATUS_TITLES);
+ const selectedKey = statusKeys[index + 1]; // first status is ALL_STATUSES
+ const selectedFilter = selectedKey ? STATUS_TITLES[selectedKey]?.id : null;
+
+ if (onFilterChange && selectedFilter) {
+ onFilterChange(selectedFilter);
+ }
+ };
+
return (
<>
-
+
{
+ setCursor('pointer');
+ },
+ onMouseOut: () => {
+ setCursor('default');
+ },
+ },
+ },
+ ]}
colorScale={
total > 0 ? chartData.map(d => d.color) : [emptyChartDonut.value]
}
labelComponent={
-
+
}
title={chartDonutTitle}
titleComponent={
@@ -97,7 +120,7 @@ const JobInvocationSystemStatusChart = ({
subTitleComponent={
// inline style overrides PatternFly default styling
}
padding={{
@@ -110,7 +133,7 @@ const JobInvocationSystemStatusChart = ({
height={chartSize}
/>
-
+
{__('System status')}
@@ -128,6 +151,32 @@ const JobInvocationSystemStatusChart = ({
colorScale={chartData.map(d => d.color)}
width={legendWidth}
height={chartSize}
+ events={[
+ {
+ target: 'data',
+ eventHandlers: {
+ onClick: onChartClick,
+ onMouseOver: () => {
+ setCursor('pointer');
+ },
+ onMouseOut: () => {
+ setCursor('default');
+ },
+ },
+ },
+ {
+ target: 'labels',
+ eventHandlers: {
+ onClick: onChartClick,
+ onMouseOver: () => {
+ setCursor('pointer');
+ },
+ onMouseOut: () => {
+ setCursor('default');
+ },
+ },
+ },
+ ]}
/>
) : (
@@ -148,10 +197,12 @@ JobInvocationSystemStatusChart.propTypes = {
data: PropTypes.object.isRequired,
isAlreadyStarted: PropTypes.bool.isRequired,
formattedStartDate: PropTypes.string,
+ onFilterChange: PropTypes.func,
};
JobInvocationSystemStatusChart.defaultProps = {
formattedStartDate: undefined,
+ onFilterChange: undefined,
};
export default JobInvocationSystemStatusChart;
diff --git a/webpack/JobInvocationDetail/__tests__/MainInformation.test.js b/webpack/JobInvocationDetail/__tests__/MainInformation.test.js
index bfcc4d158..f959eb540 100644
--- a/webpack/JobInvocationDetail/__tests__/MainInformation.test.js
+++ b/webpack/JobInvocationDetail/__tests__/MainInformation.test.js
@@ -105,7 +105,7 @@ describe('JobInvocationDetailPage', () => {
expect(screen.getByText('Succeeded: 2')).toBeInTheDocument();
expect(screen.getByText('Failed: 4')).toBeInTheDocument();
expect(screen.getByText('In Progress: 0')).toBeInTheDocument();
- expect(screen.getByText('Canceled: 0')).toBeInTheDocument();
+ expect(screen.getByText('Cancelled: 0')).toBeInTheDocument();
const informationToCheck = {
'Effective user:': jobInvocationData.effective_user,
diff --git a/webpack/JobInvocationDetail/index.js b/webpack/JobInvocationDetail/index.js
index 26bfd9da4..22e5bf381 100644
--- a/webpack/JobInvocationDetail/index.js
+++ b/webpack/JobInvocationDetail/index.js
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
-import React, { useEffect } from 'react';
+import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Divider,
@@ -51,6 +51,11 @@ const JobInvocationDetailPage = ({
currentPermissionsUrl,
CURRENT_PERMISSIONS
);
+ const [selectedFilter, setSelectedFilter] = useState('');
+
+ const handleFilterChange = filter => {
+ setSelectedFilter(filter);
+ };
let isAlreadyStarted = false;
let formattedStartDate;
@@ -79,7 +84,8 @@ const JobInvocationDetailPage = ({
if (task?.id !== undefined) {
dispatch(getTask(`${task?.id}`));
}
- }, [dispatch, task]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [dispatch, task?.id]);
const breadcrumbOptions = {
breadcrumbItems: [
@@ -113,6 +119,7 @@ const JobInvocationDetailPage = ({
data={items}
isAlreadyStarted={isAlreadyStarted}
formattedStartDate={formattedStartDate}
+ onFilterChange={handleFilterChange}
/>
)}
diff --git a/webpack/react_app/components/TargetingHosts/components/HostStatus.js b/webpack/react_app/components/TargetingHosts/components/HostStatus.js
index 62b1f2cf1..e6500f831 100644
--- a/webpack/react_app/components/TargetingHosts/components/HostStatus.js
+++ b/webpack/react_app/components/TargetingHosts/components/HostStatus.js
@@ -17,7 +17,7 @@ const HostStatus = ({ status }) => {
{__('Awaiting start')}
);
- case 'running':
+ case 'running' || 'pending':
return (
{__('Pending')}