diff --git a/awx/ui_next/src/components/StatusIcon/StatusIcon.jsx b/awx/ui_next/src/components/StatusIcon/StatusIcon.jsx index c468d2f2ddf8..c386a270e9af 100644 --- a/awx/ui_next/src/components/StatusIcon/StatusIcon.jsx +++ b/awx/ui_next/src/components/StatusIcon/StatusIcon.jsx @@ -93,7 +93,7 @@ SkippedBottom.displayName = 'SkippedBottom'; const StatusIcon = ({ status, ...props }) => { return ( -
+
{status === 'running' && } {(status === 'new' || status === 'pending' || diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx index 510190b780f0..1abf65c3a7e4 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx @@ -33,10 +33,11 @@ const StyledExclamationTriangleIcon = styled(ExclamationTriangleIcon)` function WorkflowNodeHelp({ node, i18n }) { let nodeType; - if (node.unifiedJobTemplate || node.job) { + const job = node?.originalNodeObject?.summary_fields?.job; + if (node.unifiedJobTemplate || job) { const type = node.unifiedJobTemplate ? node.unifiedJobTemplate.unified_job_type || node.unifiedJobTemplate.type - : node.job.type; + : job.type; switch (type) { case 'job_template': case 'job': @@ -64,8 +65,8 @@ function WorkflowNodeHelp({ node, i18n }) { } let jobStatus; - if (node.job) { - switch (node.job.status) { + if (job) { + switch (job.status) { case 'new': jobStatus = i18n._(t`New`); break; @@ -112,23 +113,22 @@ function WorkflowNodeHelp({ node, i18n }) { return ( <> - {!node.unifiedJobTemplate && - (!node.job || node.job.type !== 'workflow_approval') && ( - <> - - - - The resource associated with this node has been deleted. - - - - )} - {node.job && ( + {!node.unifiedJobTemplate && (!job || job.type !== 'workflow_approval') && ( + <> + + + + The resource associated with this node has been deleted. + + + + )} + {job && (
{i18n._(t`Name`)}
-
{node.job.name}
+
{job.name}
{i18n._(t`Type`)}
@@ -137,19 +137,19 @@ function WorkflowNodeHelp({ node, i18n }) { {i18n._(t`Job Status`)}
{jobStatus}
- {typeof node.job.elapsed === 'number' && ( + {typeof job.elapsed === 'number' && ( <>
{i18n._(t`Elapsed`)}
- {secondsToHHMMSS(node.job.elapsed)} + {secondsToHHMMSS(job.elapsed)}
)}
)} - {node.unifiedJobTemplate && !node.job && ( + {node.unifiedJobTemplate && !job && (
{i18n._(t`Name`)} @@ -161,7 +161,7 @@ function WorkflowNodeHelp({ node, i18n }) {
{nodeType}
)} - {node.job && node.job.type !== 'workflow_approval' && ( + {job && job.type !== 'workflow_approval' && (

{i18n._(t`Click to view job details`)}

)} diff --git a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.test.jsx b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.test.jsx index 183119f5e3c1..9ac9afbacd6b 100644 --- a/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.test.jsx +++ b/awx/ui_next/src/components/Workflow/WorkflowNodeHelp.test.jsx @@ -9,11 +9,15 @@ describe('WorkflowNodeHelp', () => { }); test('renders the expected content for a completed job template job', () => { const node = { - job: { - name: 'Foo Job Template', - elapsed: 9000, - status: 'successful', - type: 'job', + originalNodeObject: { + summary_fields: { + job: { + name: 'Foo Job Template', + elapsed: 9000, + status: 'successful', + type: 'job', + }, + }, }, unifiedJobTemplate: { name: 'Foo Job Template', diff --git a/awx/ui_next/src/components/Workflow/workflowReducer.js b/awx/ui_next/src/components/Workflow/workflowReducer.js index 1fe635c47e0c..36171811e48b 100644 --- a/awx/ui_next/src/components/Workflow/workflowReducer.js +++ b/awx/ui_next/src/components/Workflow/workflowReducer.js @@ -64,6 +64,8 @@ export default function visualizerReducer(state, action) { return { ...state, linkToDelete: action.value }; case 'SET_LINK_TO_EDIT': return { ...state, linkToEdit: action.value }; + case 'SET_NODES': + return { ...state, nodes: action.value }; case 'SET_NODE_POSITIONS': return { ...state, nodePositions: action.value }; case 'SET_NODE_TO_DELETE': @@ -363,9 +365,6 @@ function generateNodes(workflowNodes, i18n) { originalNodeObject: node, }; - if (node.summary_fields.job) { - nodeObj.job = node.summary_fields.job; - } if (node.summary_fields.unified_job_template) { nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template; } diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js index eea15f9a2c28..60fae9e90e4b 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js +++ b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import useWebsocket from '../../../util/useWebsocket'; import useThrottle from '../../../util/useThrottle'; -export default function useWsProjects( +export default function useWsInventories( initialInventories, fetchInventoriesById ) { diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.js b/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.js index 49a9b4f38742..5ea9a5222f23 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.js +++ b/awx/ui_next/src/screens/Inventory/InventorySources/useWsInventorySources.js @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import useWebsocket from '../../../util/useWebsocket'; -export default function useWsJobs(initialSources) { +export default function useWsInventorySources(initialSources) { const [sources, setSources] = useState(initialSources); const lastMessage = useWebsocket({ jobs: ['status_changed'], diff --git a/awx/ui_next/src/screens/Job/Job.jsx b/awx/ui_next/src/screens/Job/Job.jsx index 9530c4eeb374..e5692ad261b9 100644 --- a/awx/ui_next/src/screens/Job/Job.jsx +++ b/awx/ui_next/src/screens/Job/Job.jsx @@ -1,5 +1,13 @@ -import React, { Component } from 'react'; -import { Route, withRouter, Switch, Redirect, Link } from 'react-router-dom'; +import React, { useEffect, useCallback } from 'react'; +import { + Route, + withRouter, + Switch, + Redirect, + Link, + useParams, + useRouteMatch, +} from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { CaretLeftIcon } from '@patternfly/react-icons'; @@ -7,157 +15,116 @@ import { Card, PageSection } from '@patternfly/react-core'; import { JobsAPI } from '../../api'; import ContentError from '../../components/ContentError'; import RoutedTabs from '../../components/RoutedTabs'; +import useRequest from '../../util/useRequest'; import JobDetail from './JobDetail'; import JobOutput from './JobOutput'; import { WorkflowOutput } from './WorkflowOutput'; +import useWsJob from './useWsJob'; import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; -class Job extends Component { - constructor(props) { - super(props); +function Job({ i18n, lookup, setBreadcrumb }) { + const { id, type } = useParams(); + const match = useRouteMatch(); - this.state = { - job: null, - contentError: null, - hasContentLoading: true, - isInitialized: false, - }; - - this.loadJob = this.loadJob.bind(this); - } - - async componentDidMount() { - await this.loadJob(); - this.setState({ isInitialized: true }); - } - - async componentDidUpdate(prevProps) { - const { location } = this.props; - if (location !== prevProps.location) { - await this.loadJob(); - } - } - - async loadJob() { - const { match, setBreadcrumb } = this.props; - const id = parseInt(match.params.id, 10); - - this.setState({ contentError: null, hasContentLoading: true }); - try { - const { data } = await JobsAPI.readDetail(id, match.params.type); + const { isLoading, error, request: fetchJob, result } = useRequest( + useCallback(async () => { + const { data } = await JobsAPI.readDetail(id, type); setBreadcrumb(data); - this.setState({ job: data }); - } catch (err) { - this.setState({ contentError: err }); - } finally { - this.setState({ hasContentLoading: false }); - } - } - - render() { - const { match, i18n, lookup } = this.props; - - const { job, contentError, hasContentLoading, isInitialized } = this.state; - let jobType; - if (job) { - jobType = JOB_TYPE_URL_SEGMENTS[job.type]; - } - - const tabsArray = [ - { - name: ( - <> - - {i18n._(t`Back to Jobs`)} - - ), - link: `/jobs`, - id: 99, - }, - { name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 }, - { name: i18n._(t`Output`), link: `${match.url}/output`, id: 1 }, - ]; + return data; + }, [id, type, setBreadcrumb]), + null + ); - let showCardHeader = true; + useEffect(() => { + fetchJob(); + }, [fetchJob]); - if (!isInitialized) { - showCardHeader = false; - } + const job = useWsJob(result); - if (!hasContentLoading && contentError) { - return ( - - - - {contentError.response.status === 404 && ( - - {i18n._(t`The page you requested could not be found.`)}{' '} - {i18n._(t`View all Jobs.`)} - - )} - - - - ); - } - - if (lookup && job) { - return ( - - - - - - ); - } + let jobType; + if (job) { + jobType = JOB_TYPE_URL_SEGMENTS[job.type]; + } + const tabsArray = [ + { + name: ( + <> + + {i18n._(t`Back to Jobs`)} + + ), + link: `/jobs`, + id: 99, + }, + { name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 }, + { name: i18n._(t`Output`), link: `${match.url}/output`, id: 1 }, + ]; + + if (!isLoading && error) { return ( - {showCardHeader && } - - - {job && - job.type === 'workflow_job' && [ - - - , - - - , - ]} - {job && - job.type !== 'workflow_job' && [ - - - , - - - , - - {!hasContentLoading && ( - - - {i18n._(t`View Job Details`)} - - - )} - , - ]} - + + {error.response.status === 404 && ( + + {i18n._(t`The page you requested could not be found.`)}{' '} + {i18n._(t`View all Jobs.`)} + + )} + ); } + + if (lookup && job) { + return ( + + + + + + ); + } + + return ( + + + {!isLoading && } + + + {job && + job.type === 'workflow_job' && [ + + + , + + + , + ]} + {job && + job.type !== 'workflow_job' && [ + + + , + + + , + + {!isLoading && ( + + + {i18n._(t`View Job Details`)} + + + )} + , + ]} + + + + ); } export default withI18n()(withRouter(Job)); diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx index 23a87207a69d..8c50bab16c9c 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx @@ -16,6 +16,7 @@ import workflowReducer, { import { WorkflowJobsAPI } from '../../../api'; import WorkflowOutputGraph from './WorkflowOutputGraph'; import WorkflowOutputToolbar from './WorkflowOutputToolbar'; +import useWsWorkflowOutput from './useWsWorkflowOutput'; const CardBody = styled(PFCardBody)` display: flex; @@ -79,6 +80,12 @@ function WorkflowOutput({ job, i18n }) { } }, [job.id, links, nodes]); + const updatedNodes = useWsWorkflowOutput(job.id, nodes); + + useEffect(() => { + dispatch({ type: 'SET_NODES', value: updatedNodes }); + }, [updatedNodes]); + if (isLoading) { return ( diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.test.jsx index 39de8a7c0871..7a70117cef49 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.test.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputGraph.test.jsx @@ -64,11 +64,15 @@ const workflowContext = { }, { id: 2, - job: { - name: 'Foo JT', - type: 'job', - status: 'successful', - elapsed: 60, + originalNodeObject: { + summary_fields: { + job: { + name: 'Foo JT', + type: 'job', + status: 'successful', + elapsed: 60, + }, + }, }, }, { diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx index 4a566834ac37..b875e9c768ea 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.jsx @@ -64,24 +64,25 @@ Elapsed.displayName = 'Elapsed'; function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) { const history = useHistory(); const { nodePositions } = useContext(WorkflowStateContext); + const job = node?.originalNodeObject?.summary_fields?.job; let borderColor = '#93969A'; - if (node.job) { + if (job) { if ( - node.job.status === 'failed' || - node.job.status === 'error' || - node.job.status === 'canceled' + job.status === 'failed' || + job.status === 'error' || + job.status === 'canceled' ) { borderColor = '#d9534f'; } - if (node.job.status === 'successful' || node.job.status === 'ok') { + if (job.status === 'successful' || job.status === 'ok') { borderColor = '#5cb85c'; } } const handleNodeClick = () => { - if (node.job && node.job.type !== 'workflow_aproval') { - history.push(`/jobs/${node.job.id}/details`); + if (job && job.type !== 'workflow_aproval') { + history.push(`/jobs/${job.id}/details`); } }; @@ -90,7 +91,7 @@ function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) { id={`node-${node.id}`} transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id] .y - nodePositions[1].y})`} - job={node.job} + job={job} onClick={handleNodeClick} onMouseEnter={mouseEnter} onMouseLeave={mouseLeave} @@ -106,13 +107,15 @@ function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) { /> - {node.job ? ( + {job ? ( <> - {node.job.status && } -

{node.job.name}

+ {job.status && } +

{job.name || node.unifiedJobTemplate.name}

- {secondsToHHMMSS(node.job.elapsed)} + {!!job?.elapsed && ( + {secondsToHHMMSS(job.elapsed)} + )} ) : ( @@ -123,7 +126,7 @@ function WorkflowOutputNode({ i18n, mouseEnter, mouseLeave, node }) { )}
- {(node.unifiedJobTemplate || node.job) && ( + {(node.unifiedJobTemplate || job) && ( )} diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx index feb9de5b3893..a8709ccf4e51 100644 --- a/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutputNode.test.jsx @@ -5,28 +5,36 @@ import WorkflowOutputNode from './WorkflowOutputNode'; const nodeWithJT = { id: 2, - job: { - elapsed: 7, - id: 9000, - name: 'Automation JT', - status: 'successful', - type: 'job', - }, - unifiedJobTemplate: { - id: 77, - name: 'Automation JT', - unified_job_type: 'job', + originalNodeObject: { + summary_fields: { + job: { + elapsed: 7, + id: 9000, + name: 'Automation JT', + status: 'successful', + type: 'job', + }, + }, + unifiedJobTemplate: { + id: 77, + name: 'Automation JT', + unified_job_type: 'job', + }, }, }; const nodeWithoutJT = { id: 2, - job: { - elapsed: 7, - id: 9000, - name: 'Automation JT 2', - status: 'successful', - type: 'job', + originalNodeObject: { + summary_fields: { + job: { + elapsed: 7, + id: 9000, + name: 'Automation JT 2', + status: 'successful', + type: 'job', + }, + }, }, }; diff --git a/awx/ui_next/src/screens/Job/WorkflowOutput/useWsWorkflowOutput.js b/awx/ui_next/src/screens/Job/WorkflowOutput/useWsWorkflowOutput.js new file mode 100644 index 000000000000..254026d75782 --- /dev/null +++ b/awx/ui_next/src/screens/Job/WorkflowOutput/useWsWorkflowOutput.js @@ -0,0 +1,111 @@ +import { useState, useEffect } from 'react'; +import useWebsocket from '../../../util/useWebsocket'; +import { WorkflowJobsAPI } from '../../../api'; + +const fetchWorkflowNodes = async (jobId, pageNo = 1, nodes = []) => { + const { data } = await WorkflowJobsAPI.readNodes(jobId, { + page_size: 200, + page: pageNo, + }); + + if (data.next) { + return fetchWorkflowNodes(jobId, pageNo + 1, nodes.concat(data.results)); + } + return nodes.concat(data.results); +}; + +export default function useWsWorkflowOutput(workflowJobId, initialNodes) { + const [nodes, setNodes] = useState(initialNodes); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); + + useEffect(() => { + setNodes(initialNodes); + }, [initialNodes]); + + useEffect( + function parseWsMessage() { + async function refreshNodeObjects() { + const refreshedNodes = []; + const updatedNodeObjects = await fetchWorkflowNodes(workflowJobId); + const updatedNodeObjectsMap = updatedNodeObjects.reduce((map, node) => { + map[node.id] = node; + return map; + }, {}); + nodes.forEach(node => { + if (node.id === 1) { + // This is our artificial start node + refreshedNodes.push({ + ...node, + }); + } else { + refreshedNodes.push({ + ...node, + originalNodeObject: + updatedNodeObjectsMap[node.originalNodeObject.id], + }); + } + }); + setNodes(refreshedNodes); + } + + if ( + lastMessage?.unified_job_id === workflowJobId && + ['successful', 'failed', 'error', 'cancelled'].includes( + lastMessage.status + ) + ) { + refreshNodeObjects(); + } else { + if ( + !nodes || + nodes.length === 0 || + lastMessage?.workflow_job_id !== workflowJobId + ) { + return; + } + + const index = nodes.findIndex( + node => node?.originalNodeObject?.id === lastMessage.workflow_node_id + ); + + if (index > -1) { + setNodes(updateNode(nodes, index, lastMessage)); + } + } + }, + [lastMessage] // eslint-disable-line react-hooks/exhaustive-deps + ); + + return nodes; +} + +function updateNode(nodes, index, message) { + const node = { + ...nodes[index], + originalNodeObject: { + ...nodes[index]?.originalNodeObject, + job: message.unified_job_id, + summary_fields: { + ...nodes[index]?.originalNodeObject?.summary_fields, + job: { + ...nodes[index]?.originalNodeObject?.summary_fields?.job, + id: message.unified_job_id, + status: message.status, + type: message.type, + }, + }, + }, + job: { + ...nodes[index]?.job, + id: message.unified_job_id, + name: nodes[index]?.job?.name || nodes[index]?.unifiedJobTemplate?.name, + status: message.status, + type: message.type, + }, + }; + + return [...nodes.slice(0, index), node, ...nodes.slice(index + 1)]; +} diff --git a/awx/ui_next/src/screens/Job/useWsJob.js b/awx/ui_next/src/screens/Job/useWsJob.js new file mode 100644 index 000000000000..ace2cf2ce622 --- /dev/null +++ b/awx/ui_next/src/screens/Job/useWsJob.js @@ -0,0 +1,51 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import useWebsocket from '../../util/useWebsocket'; +import { JobsAPI } from '../../api'; + +export default function useWsJob(initialJob) { + const { type } = useParams(); + const [job, setJob] = useState(initialJob); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); + + useEffect(() => { + setJob(initialJob); + }, [initialJob]); + + useEffect( + function parseWsMessage() { + async function fetchJob() { + const { data } = await JobsAPI.readDetail(job.id, type); + setJob(data); + } + + if (!job || lastMessage?.unified_job_id !== job.id) { + return; + } + + if ( + ['successful', 'failed', 'error', 'cancelled'].includes( + lastMessage.status + ) + ) { + fetchJob(); + } else { + setJob(updateJob(job, lastMessage)); + } + }, + [lastMessage] // eslint-disable-line react-hooks/exhaustive-deps + ); + + return job; +} + +function updateJob(job, message) { + return { + ...job, + finished: message.finished, + status: message.status, + }; +} diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.jsx index 0409728aefd1..d9edd770307c 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateAdd/WorkflowJobTemplateAdd.jsx @@ -30,7 +30,7 @@ function WorkflowJobTemplateAdd() { data: { id }, } = await WorkflowJobTemplatesAPI.create(templatePayload); await Promise.all(await submitLabels(id, labels, organizationId)); - history.push(`/templates/workflow_job_template/${id}/details`); + history.push(`/templates/workflow_job_template/${id}/visualizer`); } catch (err) { setFormSubmitError(err); }