mirror of
https://github.com/ansible/awx.git
synced 2026-01-20 22:18:01 -03:30
Easier review workflow output (#12459)
* Adds new tab component and positions it properly on screen * Adds filtering, and navigation to node outputs
This commit is contained in:
parent
279cebcef3
commit
2c9a0444e6
@ -1,7 +1,22 @@
|
||||
import React from 'react';
|
||||
import { shape, string, number, arrayOf, node, oneOfType } from 'prop-types';
|
||||
import { Tab, Tabs, TabTitleText } from '@patternfly/react-core';
|
||||
import {
|
||||
Tab as PFTab,
|
||||
Tabs as PFTabs,
|
||||
TabTitleText,
|
||||
} from '@patternfly/react-core';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Tabs = styled(PFTabs)`
|
||||
& > ul {
|
||||
flex-grow: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Tab = styled(PFTab)`
|
||||
${(props) => props.hasstyle && `${props.hasstyle}`}
|
||||
`;
|
||||
|
||||
function RoutedTabs({ tabsArray }) {
|
||||
const history = useHistory();
|
||||
@ -31,7 +46,6 @@ function RoutedTabs({ tabsArray }) {
|
||||
history.push(link);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={getActiveTabId()}
|
||||
@ -43,10 +57,11 @@ function RoutedTabs({ tabsArray }) {
|
||||
aria-label={typeof tab.name === 'string' ? tab.name : null}
|
||||
eventKey={tab.id}
|
||||
key={tab.id}
|
||||
href={`#${tab.link}`}
|
||||
href={!tab.hasstyle && `#${tab.link}`}
|
||||
title={<TabTitleText>{tab.name}</TabTitleText>}
|
||||
aria-controls=""
|
||||
ouiaId={`${tab.name}-tab`}
|
||||
hasstyle={tab.hasstyle}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
@ -57,7 +72,6 @@ RoutedTabs.propTypes = {
|
||||
tabsArray: arrayOf(
|
||||
shape({
|
||||
id: number.isRequired,
|
||||
link: string.isRequired,
|
||||
name: oneOfType([string.isRequired, node.isRequired]),
|
||||
})
|
||||
).isRequired,
|
||||
|
||||
@ -0,0 +1,123 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Select,
|
||||
SelectOption,
|
||||
SelectGroup,
|
||||
SelectVariant,
|
||||
Chip,
|
||||
} from '@patternfly/react-core';
|
||||
import ChipGroup from 'components/ChipGroup';
|
||||
import { stringIsUUID } from 'util/strings';
|
||||
|
||||
const JOB_URL_SEGMENT_MAP = {
|
||||
job: 'playbook',
|
||||
project_update: 'project',
|
||||
system_job: 'management',
|
||||
system: 'system_job',
|
||||
inventory_update: 'inventory',
|
||||
workflow_job: 'workflow',
|
||||
};
|
||||
|
||||
function WorkflowOutputNavigation({ relatedJobs, parentRef }) {
|
||||
const { id } = useParams();
|
||||
|
||||
const relevantResults = relatedJobs.filter(
|
||||
({
|
||||
job: jobId,
|
||||
summary_fields: {
|
||||
unified_job_template: { unified_job_type },
|
||||
},
|
||||
}) => jobId && `${jobId}` !== id && unified_job_type !== 'workflow_approval'
|
||||
);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [filterBy, setFilterBy] = useState();
|
||||
const [sortedJobs, setSortedJobs] = useState(relevantResults);
|
||||
|
||||
const handleFilter = (v) => {
|
||||
if (filterBy === v) {
|
||||
setSortedJobs(relevantResults);
|
||||
setFilterBy();
|
||||
} else {
|
||||
setFilterBy(v);
|
||||
setSortedJobs(
|
||||
relevantResults.filter(
|
||||
(node) =>
|
||||
node.summary_fields.job.status === v.toLowerCase() &&
|
||||
`${node.job}` !== id
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const numSuccessJobs = relevantResults.filter(
|
||||
(node) => node.summary_fields.job.status === 'successful'
|
||||
).length;
|
||||
const numFailedJobs = relevantResults.length - numSuccessJobs;
|
||||
|
||||
return (
|
||||
<Select
|
||||
key={`${id}`}
|
||||
variant={SelectVariant.typeaheadMulti}
|
||||
menuAppendTo={parentRef?.current}
|
||||
onToggle={() => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
selections={filterBy}
|
||||
onSelect={(e, v) => {
|
||||
if (v !== 'Failed' && v !== 'Successful') return;
|
||||
handleFilter(v);
|
||||
}}
|
||||
isOpen={isOpen}
|
||||
isGrouped
|
||||
hasInlineFilter
|
||||
placeholderText={t`Workflow Job 1/${relevantResults.length}`}
|
||||
chipGroupComponent={
|
||||
<ChipGroup numChips={1} totalChips={1}>
|
||||
<Chip key={filterBy} onClick={() => handleFilter(filterBy)}>
|
||||
{[filterBy]}
|
||||
</Chip>
|
||||
</ChipGroup>
|
||||
}
|
||||
>
|
||||
{[
|
||||
<SelectGroup label={t`Workflow Statuses`} key="status">
|
||||
<SelectOption
|
||||
description={t`Filter by failed jobs`}
|
||||
key="failed"
|
||||
value={t`Failed`}
|
||||
itemCount={numFailedJobs}
|
||||
/>
|
||||
<SelectOption
|
||||
description={t`Filter by successful jobs`}
|
||||
key="successful"
|
||||
value={t`Successful`}
|
||||
itemCount={numSuccessJobs}
|
||||
/>
|
||||
</SelectGroup>,
|
||||
<SelectGroup label={t`Workflow Nodes`} key="nodes">
|
||||
{sortedJobs?.map((node) => (
|
||||
<SelectOption
|
||||
key={node.id}
|
||||
to={`/jobs/${
|
||||
JOB_URL_SEGMENT_MAP[
|
||||
node.summary_fields.unified_job_template.unified_job_type
|
||||
]
|
||||
}/${node.summary_fields.job?.id}/output`}
|
||||
component={Link}
|
||||
value={node.summary_fields.unified_job_template.name}
|
||||
>
|
||||
{stringIsUUID(node.identifier)
|
||||
? node.summary_fields.unified_job_template.name
|
||||
: node.identifier}
|
||||
</SelectOption>
|
||||
))}
|
||||
</SelectGroup>,
|
||||
]}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowOutputNavigation;
|
||||
1
awx/ui/src/components/WorkflowOutputNavigation/index.js
Normal file
1
awx/ui/src/components/WorkflowOutputNavigation/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './WorkflowOutputNavigation';
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Route,
|
||||
Switch,
|
||||
@ -18,6 +18,7 @@ import RoutedTabs from 'components/RoutedTabs';
|
||||
import { getSearchableKeys } from 'components/PaginatedTable';
|
||||
import useRequest from 'hooks/useRequest';
|
||||
import { getJobModel } from 'util/jobs';
|
||||
import WorkflowOutputNavigation from 'components/WorkflowOutputNavigation';
|
||||
import JobDetail from './JobDetail';
|
||||
import JobOutput from './JobOutput';
|
||||
import { WorkflowOutput } from './WorkflowOutput';
|
||||
@ -49,10 +50,12 @@ function Job({ setBreadcrumb }) {
|
||||
eventRelatedSearchableKeys,
|
||||
eventSearchableKeys,
|
||||
inventorySourceChoices,
|
||||
relatedJobs,
|
||||
},
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
let eventOptions = {};
|
||||
let relatedJobData = {};
|
||||
const { data: jobDetailData } = await getJobModel(type).readDetail(id);
|
||||
if (type !== 'workflow_job') {
|
||||
const { data: jobEventOptions } = await getJobModel(
|
||||
@ -60,6 +63,14 @@ function Job({ setBreadcrumb }) {
|
||||
).readEventOptions(id);
|
||||
eventOptions = jobEventOptions;
|
||||
}
|
||||
if (jobDetailData.related.source_workflow_job) {
|
||||
const {
|
||||
data: { results },
|
||||
} = await getJobModel('workflow_job').readNodes(
|
||||
jobDetailData.summary_fields.source_workflow_job.id
|
||||
);
|
||||
relatedJobData = results;
|
||||
}
|
||||
if (
|
||||
jobDetailData?.summary_fields?.credentials?.find(
|
||||
(cred) => cred.kind === 'vault'
|
||||
@ -82,6 +93,7 @@ function Job({ setBreadcrumb }) {
|
||||
inventorySourceChoices:
|
||||
choices?.data?.actions?.GET?.source?.choices || [],
|
||||
jobDetail: jobDetailData,
|
||||
relatedJobs: relatedJobData,
|
||||
eventRelatedSearchableKeys: (
|
||||
eventOptions?.related_search_fields || []
|
||||
).map((val) => val.slice(0, -8)),
|
||||
@ -93,6 +105,7 @@ function Job({ setBreadcrumb }) {
|
||||
inventorySourceChoices: [],
|
||||
eventRelatedSearchableKeys: [],
|
||||
eventSearchableKeys: [],
|
||||
relatedJobs: [],
|
||||
}
|
||||
);
|
||||
|
||||
@ -101,7 +114,7 @@ function Job({ setBreadcrumb }) {
|
||||
}, [fetchJob]);
|
||||
|
||||
const job = useWsJob(jobDetail);
|
||||
|
||||
const ref = useRef(null);
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
@ -117,6 +130,16 @@ function Job({ setBreadcrumb }) {
|
||||
{ name: t`Details`, link: `${match.url}/details`, id: 0 },
|
||||
{ name: t`Output`, link: `${match.url}/output`, id: 1 },
|
||||
];
|
||||
if (relatedJobs?.length > 0) {
|
||||
tabsArray.push({
|
||||
name: (
|
||||
<WorkflowOutputNavigation parentRef={ref} relatedJobs={relatedJobs} />
|
||||
),
|
||||
link: undefined,
|
||||
id: 2,
|
||||
hasstyle: 'margin-left: auto',
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@ -147,46 +170,53 @@ function Job({ setBreadcrumb }) {
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<Switch>
|
||||
<Redirect from="/jobs/system/:id" to="/jobs/management/:id" exact />
|
||||
<Redirect
|
||||
from="/jobs/:typeSegment/:id"
|
||||
to="/jobs/:typeSegment/:id/output"
|
||||
exact
|
||||
<div ref={ref}>
|
||||
<Card>
|
||||
<RoutedTabs
|
||||
isWorkflow={match.url.startsWith('/jobs/workflow')}
|
||||
tabsArray={tabsArray}
|
||||
/>
|
||||
{job && [
|
||||
<Route
|
||||
key={job.type === 'workflow_job' ? 'workflow-details' : 'details'}
|
||||
path="/jobs/:typeSegment/:id/details"
|
||||
>
|
||||
<JobDetail
|
||||
job={job}
|
||||
inventorySourceLabels={inventorySourceChoices}
|
||||
/>
|
||||
</Route>,
|
||||
<Route key="output" path="/jobs/:typeSegment/:id/output">
|
||||
{job.type === 'workflow_job' ? (
|
||||
<WorkflowOutput job={job} />
|
||||
) : (
|
||||
<JobOutput
|
||||
<Switch>
|
||||
<Redirect from="/jobs/system/:id" to="/jobs/management/:id" exact />
|
||||
<Redirect
|
||||
from="/jobs/:typeSegment/:id"
|
||||
to="/jobs/:typeSegment/:id/output"
|
||||
exact
|
||||
/>
|
||||
{job && [
|
||||
<Route
|
||||
key={
|
||||
job.type === 'workflow_job' ? 'workflow-details' : 'details'
|
||||
}
|
||||
path="/jobs/:typeSegment/:id/details"
|
||||
>
|
||||
<JobDetail
|
||||
job={job}
|
||||
eventRelatedSearchableKeys={eventRelatedSearchableKeys}
|
||||
eventSearchableKeys={eventSearchableKeys}
|
||||
inventorySourceLabels={inventorySourceChoices}
|
||||
/>
|
||||
)}
|
||||
</Route>,
|
||||
<Route key="not-found" path="*">
|
||||
<ContentError isNotFound>
|
||||
<Link to={`/jobs/${typeSegment}/${id}/details`}>
|
||||
{t`View Job Details`}
|
||||
</Link>
|
||||
</ContentError>
|
||||
</Route>,
|
||||
]}
|
||||
</Switch>
|
||||
</Card>
|
||||
</Route>,
|
||||
<Route key="output" path="/jobs/:typeSegment/:id/output">
|
||||
{job.type === 'workflow_job' ? (
|
||||
<WorkflowOutput job={job} />
|
||||
) : (
|
||||
<JobOutput
|
||||
job={job}
|
||||
eventRelatedSearchableKeys={eventRelatedSearchableKeys}
|
||||
eventSearchableKeys={eventSearchableKeys}
|
||||
/>
|
||||
)}
|
||||
</Route>,
|
||||
<Route key="not-found" path="*">
|
||||
<ContentError isNotFound>
|
||||
<Link to={`/jobs/${typeSegment}/${id}/details`}>
|
||||
{t`View Job Details`}
|
||||
</Link>
|
||||
</ContentError>
|
||||
</Route>,
|
||||
]}
|
||||
</Switch>
|
||||
</Card>
|
||||
</div>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user