mirror of
https://github.com/ansible/awx.git
synced 2026-05-14 04:47:44 -02: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:
@@ -1,7 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { shape, string, number, arrayOf, node, oneOfType } from 'prop-types';
|
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 { 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 }) {
|
function RoutedTabs({ tabsArray }) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@@ -31,7 +46,6 @@ function RoutedTabs({ tabsArray }) {
|
|||||||
history.push(link);
|
history.push(link);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={getActiveTabId()}
|
activeKey={getActiveTabId()}
|
||||||
@@ -43,10 +57,11 @@ function RoutedTabs({ tabsArray }) {
|
|||||||
aria-label={typeof tab.name === 'string' ? tab.name : null}
|
aria-label={typeof tab.name === 'string' ? tab.name : null}
|
||||||
eventKey={tab.id}
|
eventKey={tab.id}
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
href={`#${tab.link}`}
|
href={!tab.hasstyle && `#${tab.link}`}
|
||||||
title={<TabTitleText>{tab.name}</TabTitleText>}
|
title={<TabTitleText>{tab.name}</TabTitleText>}
|
||||||
aria-controls=""
|
aria-controls=""
|
||||||
ouiaId={`${tab.name}-tab`}
|
ouiaId={`${tab.name}-tab`}
|
||||||
|
hasstyle={tab.hasstyle}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -57,7 +72,6 @@ RoutedTabs.propTypes = {
|
|||||||
tabsArray: arrayOf(
|
tabsArray: arrayOf(
|
||||||
shape({
|
shape({
|
||||||
id: number.isRequired,
|
id: number.isRequired,
|
||||||
link: string.isRequired,
|
|
||||||
name: oneOfType([string.isRequired, node.isRequired]),
|
name: oneOfType([string.isRequired, node.isRequired]),
|
||||||
})
|
})
|
||||||
).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 {
|
import {
|
||||||
Route,
|
Route,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -18,6 +18,7 @@ import RoutedTabs from 'components/RoutedTabs';
|
|||||||
import { getSearchableKeys } from 'components/PaginatedTable';
|
import { getSearchableKeys } from 'components/PaginatedTable';
|
||||||
import useRequest from 'hooks/useRequest';
|
import useRequest from 'hooks/useRequest';
|
||||||
import { getJobModel } from 'util/jobs';
|
import { getJobModel } from 'util/jobs';
|
||||||
|
import WorkflowOutputNavigation from 'components/WorkflowOutputNavigation';
|
||||||
import JobDetail from './JobDetail';
|
import JobDetail from './JobDetail';
|
||||||
import JobOutput from './JobOutput';
|
import JobOutput from './JobOutput';
|
||||||
import { WorkflowOutput } from './WorkflowOutput';
|
import { WorkflowOutput } from './WorkflowOutput';
|
||||||
@@ -49,10 +50,12 @@ function Job({ setBreadcrumb }) {
|
|||||||
eventRelatedSearchableKeys,
|
eventRelatedSearchableKeys,
|
||||||
eventSearchableKeys,
|
eventSearchableKeys,
|
||||||
inventorySourceChoices,
|
inventorySourceChoices,
|
||||||
|
relatedJobs,
|
||||||
},
|
},
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
let eventOptions = {};
|
let eventOptions = {};
|
||||||
|
let relatedJobData = {};
|
||||||
const { data: jobDetailData } = await getJobModel(type).readDetail(id);
|
const { data: jobDetailData } = await getJobModel(type).readDetail(id);
|
||||||
if (type !== 'workflow_job') {
|
if (type !== 'workflow_job') {
|
||||||
const { data: jobEventOptions } = await getJobModel(
|
const { data: jobEventOptions } = await getJobModel(
|
||||||
@@ -60,6 +63,14 @@ function Job({ setBreadcrumb }) {
|
|||||||
).readEventOptions(id);
|
).readEventOptions(id);
|
||||||
eventOptions = jobEventOptions;
|
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 (
|
if (
|
||||||
jobDetailData?.summary_fields?.credentials?.find(
|
jobDetailData?.summary_fields?.credentials?.find(
|
||||||
(cred) => cred.kind === 'vault'
|
(cred) => cred.kind === 'vault'
|
||||||
@@ -82,6 +93,7 @@ function Job({ setBreadcrumb }) {
|
|||||||
inventorySourceChoices:
|
inventorySourceChoices:
|
||||||
choices?.data?.actions?.GET?.source?.choices || [],
|
choices?.data?.actions?.GET?.source?.choices || [],
|
||||||
jobDetail: jobDetailData,
|
jobDetail: jobDetailData,
|
||||||
|
relatedJobs: relatedJobData,
|
||||||
eventRelatedSearchableKeys: (
|
eventRelatedSearchableKeys: (
|
||||||
eventOptions?.related_search_fields || []
|
eventOptions?.related_search_fields || []
|
||||||
).map((val) => val.slice(0, -8)),
|
).map((val) => val.slice(0, -8)),
|
||||||
@@ -93,6 +105,7 @@ function Job({ setBreadcrumb }) {
|
|||||||
inventorySourceChoices: [],
|
inventorySourceChoices: [],
|
||||||
eventRelatedSearchableKeys: [],
|
eventRelatedSearchableKeys: [],
|
||||||
eventSearchableKeys: [],
|
eventSearchableKeys: [],
|
||||||
|
relatedJobs: [],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -101,7 +114,7 @@ function Job({ setBreadcrumb }) {
|
|||||||
}, [fetchJob]);
|
}, [fetchJob]);
|
||||||
|
|
||||||
const job = useWsJob(jobDetail);
|
const job = useWsJob(jobDetail);
|
||||||
|
const ref = useRef(null);
|
||||||
const tabsArray = [
|
const tabsArray = [
|
||||||
{
|
{
|
||||||
name: (
|
name: (
|
||||||
@@ -117,6 +130,16 @@ function Job({ setBreadcrumb }) {
|
|||||||
{ name: t`Details`, link: `${match.url}/details`, id: 0 },
|
{ name: t`Details`, link: `${match.url}/details`, id: 0 },
|
||||||
{ name: t`Output`, link: `${match.url}/output`, id: 1 },
|
{ 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -147,46 +170,53 @@ function Job({ setBreadcrumb }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<div ref={ref}>
|
||||||
<RoutedTabs tabsArray={tabsArray} />
|
<Card>
|
||||||
<Switch>
|
<RoutedTabs
|
||||||
<Redirect from="/jobs/system/:id" to="/jobs/management/:id" exact />
|
isWorkflow={match.url.startsWith('/jobs/workflow')}
|
||||||
<Redirect
|
tabsArray={tabsArray}
|
||||||
from="/jobs/:typeSegment/:id"
|
|
||||||
to="/jobs/:typeSegment/:id/output"
|
|
||||||
exact
|
|
||||||
/>
|
/>
|
||||||
{job && [
|
<Switch>
|
||||||
<Route
|
<Redirect from="/jobs/system/:id" to="/jobs/management/:id" exact />
|
||||||
key={job.type === 'workflow_job' ? 'workflow-details' : 'details'}
|
<Redirect
|
||||||
path="/jobs/:typeSegment/:id/details"
|
from="/jobs/:typeSegment/:id"
|
||||||
>
|
to="/jobs/:typeSegment/:id/output"
|
||||||
<JobDetail
|
exact
|
||||||
job={job}
|
/>
|
||||||
inventorySourceLabels={inventorySourceChoices}
|
{job && [
|
||||||
/>
|
<Route
|
||||||
</Route>,
|
key={
|
||||||
<Route key="output" path="/jobs/:typeSegment/:id/output">
|
job.type === 'workflow_job' ? 'workflow-details' : 'details'
|
||||||
{job.type === 'workflow_job' ? (
|
}
|
||||||
<WorkflowOutput job={job} />
|
path="/jobs/:typeSegment/:id/details"
|
||||||
) : (
|
>
|
||||||
<JobOutput
|
<JobDetail
|
||||||
job={job}
|
job={job}
|
||||||
eventRelatedSearchableKeys={eventRelatedSearchableKeys}
|
inventorySourceLabels={inventorySourceChoices}
|
||||||
eventSearchableKeys={eventSearchableKeys}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</Route>,
|
||||||
</Route>,
|
<Route key="output" path="/jobs/:typeSegment/:id/output">
|
||||||
<Route key="not-found" path="*">
|
{job.type === 'workflow_job' ? (
|
||||||
<ContentError isNotFound>
|
<WorkflowOutput job={job} />
|
||||||
<Link to={`/jobs/${typeSegment}/${id}/details`}>
|
) : (
|
||||||
{t`View Job Details`}
|
<JobOutput
|
||||||
</Link>
|
job={job}
|
||||||
</ContentError>
|
eventRelatedSearchableKeys={eventRelatedSearchableKeys}
|
||||||
</Route>,
|
eventSearchableKeys={eventSearchableKeys}
|
||||||
]}
|
/>
|
||||||
</Switch>
|
)}
|
||||||
</Card>
|
</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>
|
</PageSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user