Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes #38090 - Add filtering to job invocation hosts table #934

Merged
merged 6 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/controllers/api/v2/job_invocations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 14 additions & 2 deletions webpack/JobInvocationDetail/JobInvocationConstants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 };
Expand All @@ -84,13 +92,15 @@ const Columns = () => {
wrapper: ({ name }) => (
<a href={`${hostDetailsPageUrl}${name}`}>{name}</a>
),
isSorted: true,
weight: 1,
},
groups: {
hostgroup: {
title: __('Host group'),
wrapper: ({ hostgroup_id, hostgroup_name }) => (
<a href={`/hostgroups/${hostgroup_id}/edit`}>{hostgroup_name}</a>
),
isSorted: true,
weight: 2,
},
os: {
Expand All @@ -100,13 +110,15 @@ const Columns = () => {
{operatingsystem_name}
</a>
),
isSorted: true,
weight: 3,
},
smart_proxy: {
title: __('Smart proxy'),
wrapper: ({ smart_proxy_name, smart_proxy_id }) => (
<a href={`/smart_proxies/${smart_proxy_id}`}>{smart_proxy_name}</a>
),
isSorted: true,
weight: 4,
},
status: {
Expand Down
135 changes: 84 additions & 51 deletions webpack/JobInvocationDetail/JobInvocationHostTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,58 +28,64 @@ 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([]);
const { response, status, setAPIOptions } = useAPI(
'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(
Expand All @@ -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) {
Expand All @@ -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 = (
<Pagination
ouiaId="table-hosts-bottom-pagination"
key="table-bottom-pagination"
page={params.page}
perPage={params.perPage}
itemCount={response?.subtotal}
onChange={onPagination}
/>
);
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 = (
<Tr ouiaId="table-empty">
Expand Down Expand Up @@ -183,26 +212,30 @@ const JobInvocationHostTable = ({ id, targeting, finished, autoRefresh }) => {
creatable={false}
replacementResponse={combinedResponse}
updateSearchQuery={updateSearchQuery}
customToolbarItems={
customToolbarItems={[
<OpenAlInvocations
setShowAlert={setShowAlert}
results={results}
id={id}
/>
}
/>,
<JobInvocationHostTableToolbar
dropdownFilter={selectedFilter}
setDropdownFilter={wrapSetSelectedFilter}
/>,
]}
>
<Table
ouiaId="job-invocation-hosts-table"
columns={columns}
customEmptyState={
status === STATUS_UPPERCASE.RESOLVED && !results?.length
status === STATUS_UPPERCASE.RESOLVED && !response?.results?.length
? customEmptyState
: null
}
params={params}
setParams={setParamsAndAPI}
setParams={wrapSetAPIOptions}
itemCount={response?.subtotal}
results={results}
results={response?.results}
url=""
refreshData={() => {}}
errorMessage={
Expand All @@ -212,7 +245,6 @@ const JobInvocationHostTable = ({ id, targeting, finished, autoRefresh }) => {
}
isPending={status === STATUS_UPPERCASE.PENDING}
isDeleteable={false}
bottomPagination={bottomPagination}
childrenOutsideTbody
>
{results?.map((result, rowIndex) => (
Expand Down Expand Up @@ -267,6 +299,7 @@ JobInvocationHostTable.propTypes = {
targeting: PropTypes.object.isRequired,
finished: PropTypes.bool.isRequired,
autoRefresh: PropTypes.bool.isRequired,
initialFilter: PropTypes.string.isRequired,
};

JobInvocationHostTable.defaultProps = {};
Expand Down
63 changes: 63 additions & 0 deletions webpack/JobInvocationDetail/JobInvocationHostTableToolbar.js
Original file line number Diff line number Diff line change
@@ -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 => (
<MenuToggle
ref={toggleRef}
onClick={() => setIsOpen(!isOpen)}
isExpanded={isOpen}
style={{
width: '200px',
}}
>
{Object.values(STATUS_TITLES).find(status => status.id === dropdownFilter)
?.title || __('All statuses')}
</MenuToggle>
);

return (
<ToolbarItem>
<Select
isOpen={isOpen}
selected={dropdownFilter}
onSelect={onSelect}
onOpenChange={newIsOpen => setIsOpen(newIsOpen)}
ouiaId="host-status-select"
toggle={toggle}
>
<SelectList>
{Object.values(STATUS_TITLES).map(result => (
<SelectOption
key={result.id}
itemId={result.id}
isSelected={result.id === dropdownFilter}
>
{result.title}
</SelectOption>
))}
</SelectList>
</Select>
</ToolbarItem>
);
};

JobInvocationHostTableToolbar.propTypes = {
dropdownFilter: PropTypes.string.isRequired,
setDropdownFilter: PropTypes.func.isRequired,
};

export default JobInvocationHostTableToolbar;
Loading
Loading