From fbd47538b44d7d74e4cd52b26fa3a898eda49fb8 Mon Sep 17 00:00:00 2001 From: blaipr Date: Thu, 11 Jun 2026 21:13:05 +0200 Subject: [PATCH 1/4] Show elapsed time on the workflow job output toolbar Regular job output pages show a live hh:mm:ss elapsed badge that ticks every second while the job runs and switches to the API's final elapsed value on completion. Workflow job output pages showed no elapsed time at all, live or final. The workflow output toolbar now renders the same badge next to Total Nodes: a one-second interval recomputes elapsed from job.started while the workflow runs, and the websocket-driven job refetch (useWsJob refetches on terminal statuses) delivers finished/elapsed so the badge settles on the authoritative value without a reload. calculateElapsed moves from a private helper inside OutputToolbar to util/dates alongside secondsToHHMMSS, and OutputToolbar now uses both shared helpers; no behavior change there. Both strings already exist in the locale catalogs, so no catalog changes are needed. --- awx/ui/package-lock.json | 2 +- .../Job/JobOutput/shared/OutputToolbar.js | 21 +------ .../WorkflowOutput/WorkflowOutputToolbar.js | 23 +++++++ .../WorkflowOutputToolbar.test.js | 62 ++++++++++++++++++- awx/ui/src/util/dates.js | 14 +++++ awx/ui/src/util/dates.test.js | 24 +++++++ 6 files changed, 124 insertions(+), 22 deletions(-) diff --git a/awx/ui/package-lock.json b/awx/ui/package-lock.json index 820b0f582..343db965f 100644 --- a/awx/ui/package-lock.json +++ b/awx/ui/package-lock.json @@ -23760,7 +23760,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "peer": true, "bin": { diff --git a/awx/ui/src/screens/Job/JobOutput/shared/OutputToolbar.js b/awx/ui/src/screens/Job/JobOutput/shared/OutputToolbar.js index f9bd12805..c9b168c09 100644 --- a/awx/ui/src/screens/Job/JobOutput/shared/OutputToolbar.js +++ b/awx/ui/src/screens/Job/JobOutput/shared/OutputToolbar.js @@ -1,6 +1,6 @@ import React, { useEffect, useState, useRef } from 'react'; import styled from 'styled-components'; -import { DateTime, Duration } from 'luxon'; +import { calculateElapsed, secondsToHHMMSS } from 'util/dates'; import { bool, shape, func } from 'prop-types'; import { DownloadIcon, @@ -41,21 +41,6 @@ const Wrapper = styled.div` flex-flow: row wrap; font-size: 14px; `; -const calculateElapsed = (started) => { - if (!started) return '00:00:00'; - const now = DateTime.now(); - const duration = now - .diff(DateTime.fromISO(`${started}`), [ - 'milliseconds', - 'seconds', - 'minutes', - 'hours', - ]) - .toObject(); - - return Duration.fromObject({ ...duration }).toFormat('hh:mm:ss'); -}; - const OUTPUT_NO_COUNT_JOB_TYPES = [ 'ad_hoc_command', 'system_job', @@ -153,9 +138,7 @@ const OutputToolbar = ({ job, onDelete, isDeleteDisabled, jobStatus }) => { {job.finished - ? Duration.fromObject({ seconds: job.elapsed }).toFormat( - 'hh:mm:ss' - ) + ? secondsToHHMMSS(job.elapsed) : activeJobElapsedTime} diff --git a/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.js b/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.js index 52f32b38e..df16dad28 100644 --- a/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.js +++ b/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.js @@ -11,6 +11,7 @@ import { } from '@patternfly/react-icons'; import styled from 'styled-components'; import StatusLabel from 'components/StatusLabel'; +import { calculateElapsed, secondsToHHMMSS } from 'util/dates'; import JobCancelButton from 'components/JobCancelButton'; import { WorkflowDispatchContext, @@ -71,6 +72,20 @@ function WorkflowOutputToolbar({ job }) { job.summary_fields?.workflow_job_template?.id ?? job.summary_fields?.workflow_job_template?.[0]?.id; + const [activeJobElapsedTime, setActiveJobElapsedTime] = React.useState( + calculateElapsed(job.started) + ); + + React.useEffect(() => { + let secTimer; + if (job.started && !job.finished) { + secTimer = setInterval(() => { + setActiveJobElapsedTime(calculateElapsed(job.started)); + }, 1000); + } + return () => clearInterval(secTimer); + }, [job.started, job.finished]); + const totalNodes = nodes.reduce((n, node) => n + !node.isDeleted, 0) - 1; const navToWorkflow = () => { if (workflowTemplateId) { @@ -109,6 +124,14 @@ function WorkflowOutputToolbar({ job }) { )} +
{t`Elapsed`}
+ + + {job.finished + ? secondsToHHMMSS(job.elapsed) + : activeJobElapsedTime} + +
{t`Total Nodes`}
{totalNodes} diff --git a/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.js b/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.js index 944413226..ffca88682 100644 --- a/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.js +++ b/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.js @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { WorkflowDispatchContext, WorkflowStateContext, @@ -57,14 +58,14 @@ describe('WorkflowOutputToolbar', () => { test('should render correct toolbar item', () => { shouldFind(`Button[ouiaId="edit-workflow"]`); shouldFind('Button#workflow-output-toggle-legend'); - shouldFind('Badge'); + expect(wrapper.find('Badge')).toHaveLength(2); shouldFind('Button#workflow-output-toggle-tools'); shouldFind('JobCancelButton'); }); test('Shows correct number of nodes', () => { // The start node (id=1) and deleted nodes (isDeleted=true) should be ignored - expect(wrapper.find('Badge').text()).toBe('1'); + expect(wrapper.find('Badge').last().text()).toBe('1'); }); test('Toggle Legend button dispatches as expected', () => { @@ -109,4 +110,61 @@ describe('WorkflowOutputToolbar', () => { 0 ); }); + + describe('elapsed timer', () => { + beforeEach(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date('2021-09-01T12:30:45.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + function mountToolbar(jobOverrides) { + return mountWithContexts( + + + + + + ); + } + + test('should show live elapsed time while running', () => { + const runningWrapper = mountToolbar({ + started: '2021-09-01T12:30:40.000Z', + finished: null, + }); + expect( + runningWrapper.find('Badge#workflow-elapsed-badge').text() + ).toBe('00:00:05'); + act(() => { + jest.advanceTimersByTime(2000); + }); + runningWrapper.update(); + expect( + runningWrapper.find('Badge#workflow-elapsed-badge').text() + ).toBe('00:00:07'); + }); + + test('should show final elapsed time once finished', () => { + const finishedWrapper = mountToolbar({ + status: 'successful', + started: '2021-09-01T11:00:00.000Z', + finished: '2021-09-01T12:01:01.000Z', + elapsed: 3661, + }); + expect( + finishedWrapper.find('Badge#workflow-elapsed-badge').text() + ).toBe('01:01:01'); + act(() => { + jest.advanceTimersByTime(2000); + }); + finishedWrapper.update(); + expect( + finishedWrapper.find('Badge#workflow-elapsed-badge').text() + ).toBe('01:01:01'); + }); + }); }); diff --git a/awx/ui/src/util/dates.js b/awx/ui/src/util/dates.js index 616ff438c..e3847762f 100644 --- a/awx/ui/src/util/dates.js +++ b/awx/ui/src/util/dates.js @@ -21,6 +21,20 @@ export function secondsToHHMMSS(seconds) { return Duration.fromObject({ seconds }).toFormat('hh:mm:ss'); } +export function calculateElapsed(started) { + if (!started) return '00:00:00'; + const duration = DateTime.now() + .diff(DateTime.fromISO(`${started}`), [ + 'milliseconds', + 'seconds', + 'minutes', + 'hours', + ]) + .toObject(); + + return Duration.fromObject({ ...duration }).toFormat('hh:mm:ss'); +} + export function secondsToDays(seconds) { return Duration.fromObject({ seconds }).toFormat('d'); } diff --git a/awx/ui/src/util/dates.test.js b/awx/ui/src/util/dates.test.js index 4b8fd7671..65917c286 100644 --- a/awx/ui/src/util/dates.test.js +++ b/awx/ui/src/util/dates.test.js @@ -1,5 +1,6 @@ import { RRule } from 'rrule'; import { + calculateElapsed, dateToInputDateTime, formatDateString, getRRuleDayConstants, @@ -44,6 +45,29 @@ describe('secondsToDays', () => { }); }); +describe('calculateElapsed', () => { + beforeEach(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date('2021-09-01T12:30:45.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('should return zero when not started', () => { + expect(calculateElapsed(null)).toEqual('00:00:00'); + expect(calculateElapsed(undefined)).toEqual('00:00:00'); + expect(calculateElapsed('')).toEqual('00:00:00'); + }); + + test('should compute elapsed time from start timestamp', () => { + expect(calculateElapsed('2021-09-01T12:30:44.000Z')).toEqual('00:00:01'); + expect(calculateElapsed('2021-09-01T12:29:45.000Z')).toEqual('00:01:00'); + expect(calculateElapsed('2021-09-01T10:00:00.000Z')).toEqual('02:30:45'); + }); +}); + describe('secondsToHHMMSS', () => { test('it returns the expected value', () => { expect(secondsToHHMMSS(50000)).toEqual('13:53:20'); From 182f1ffa7df44b14ca5af9e705ebc39ccc9186d8 Mon Sep 17 00:00:00 2001 From: blaipr Date: Fri, 12 Jun 2026 08:15:07 +0200 Subject: [PATCH 2/4] Address review: guard elapsed during ws window, de-brittle badge tests - When a websocket status_changed message sets job.finished before the follow-up refetch delivers the authoritative job.elapsed, keep showing the last live timer value instead of formatting undefined (applied to both toolbars; new test covers the window) - Select badges by id in tests instead of count/order - Revert accidental package-lock metadata churn --- awx/ui/package-lock.json | 2 +- .../Job/JobOutput/shared/OutputToolbar.js | 2 +- .../WorkflowOutput/WorkflowOutputToolbar.js | 2 +- .../WorkflowOutputToolbar.test.js | 21 +++++++++++++++++-- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/awx/ui/package-lock.json b/awx/ui/package-lock.json index 343db965f..820b0f582 100644 --- a/awx/ui/package-lock.json +++ b/awx/ui/package-lock.json @@ -23760,7 +23760,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "peer": true, "bin": { diff --git a/awx/ui/src/screens/Job/JobOutput/shared/OutputToolbar.js b/awx/ui/src/screens/Job/JobOutput/shared/OutputToolbar.js index c9b168c09..79313add3 100644 --- a/awx/ui/src/screens/Job/JobOutput/shared/OutputToolbar.js +++ b/awx/ui/src/screens/Job/JobOutput/shared/OutputToolbar.js @@ -137,7 +137,7 @@ const OutputToolbar = ({ job, onDelete, isDeleteDisabled, jobStatus }) => {
{t`Elapsed`}
- {job.finished + {job.finished && job.elapsed != null ? secondsToHHMMSS(job.elapsed) : activeJobElapsedTime} diff --git a/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.js b/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.js index df16dad28..0c7e5b03a 100644 --- a/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.js +++ b/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.js @@ -127,7 +127,7 @@ function WorkflowOutputToolbar({ job }) {
{t`Elapsed`}
- {job.finished + {job.finished && job.elapsed != null ? secondsToHHMMSS(job.elapsed) : activeJobElapsedTime} diff --git a/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.js b/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.js index ffca88682..c96697b5f 100644 --- a/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.js +++ b/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.test.js @@ -58,14 +58,19 @@ describe('WorkflowOutputToolbar', () => { test('should render correct toolbar item', () => { shouldFind(`Button[ouiaId="edit-workflow"]`); shouldFind('Button#workflow-output-toggle-legend'); - expect(wrapper.find('Badge')).toHaveLength(2); + shouldFind('Badge#workflow-elapsed-badge'); shouldFind('Button#workflow-output-toggle-tools'); shouldFind('JobCancelButton'); }); test('Shows correct number of nodes', () => { // The start node (id=1) and deleted nodes (isDeleted=true) should be ignored - expect(wrapper.find('Badge').last().text()).toBe('1'); + expect( + wrapper + .find('Badge') + .filterWhere((b) => b.prop('id') !== 'workflow-elapsed-badge') + .text() + ).toBe('1'); }); test('Toggle Legend button dispatches as expected', () => { @@ -148,6 +153,18 @@ describe('WorkflowOutputToolbar', () => { ).toBe('00:00:07'); }); + test('should keep the live value while finished is set but elapsed has not arrived yet', () => { + const wsWindowWrapper = mountToolbar({ + status: 'successful', + started: '2021-09-01T12:30:40.000Z', + finished: '2021-09-01T12:30:45.000Z', + elapsed: undefined, + }); + expect( + wsWindowWrapper.find('Badge#workflow-elapsed-badge').text() + ).toBe('00:00:05'); + }); + test('should show final elapsed time once finished', () => { const finishedWrapper = mountToolbar({ status: 'successful', From e01b267f35d6c415374e32b0d9775ba663ab0741 Mon Sep 17 00:00:00 2001 From: blaipr Date: Fri, 12 Jun 2026 15:38:04 +0200 Subject: [PATCH 3/4] Address review: space the elapsed badge and stabilize its width The elapsed badge sat flush against the Total Nodes label and resized as the ticking digits changed width. It now has a 20px right margin, a 70px minimum width and tabular-nums digits, so the toolbar no longer shifts as the timer runs. The job output toolbar's elapsed badge gets the same width treatment (it had the identical wobble; its spacing was already handled by BadgeGroup). --- awx/ui/package-lock.json | 2 +- .../src/screens/Job/JobOutput/shared/OutputToolbar.js | 9 +++++++-- .../Job/WorkflowOutput/WorkflowOutputToolbar.js | 10 ++++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/awx/ui/package-lock.json b/awx/ui/package-lock.json index 820b0f582..343db965f 100644 --- a/awx/ui/package-lock.json +++ b/awx/ui/package-lock.json @@ -23760,7 +23760,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "peer": true, "bin": { diff --git a/awx/ui/src/screens/Job/JobOutput/shared/OutputToolbar.js b/awx/ui/src/screens/Job/JobOutput/shared/OutputToolbar.js index 79313add3..3df5cea9f 100644 --- a/awx/ui/src/screens/Job/JobOutput/shared/OutputToolbar.js +++ b/awx/ui/src/screens/Job/JobOutput/shared/OutputToolbar.js @@ -35,6 +35,11 @@ const Badge = styled(PFBadge)` : null} `; +const ElapsedBadge = styled(Badge)` + min-width: 70px; + font-variant-numeric: tabular-nums; +`; + const Wrapper = styled.div` align-items: center; display: flex; @@ -136,11 +141,11 @@ const OutputToolbar = ({ job, onDelete, isDeleteDisabled, jobStatus }) => {
{t`Elapsed`}
- + {job.finished && job.elapsed != null ? secondsToHHMMSS(job.elapsed) : activeJobElapsedTime} - +
{['pending', 'waiting', 'running'].includes(jobStatus) && diff --git a/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.js b/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.js index 0c7e5b03a..a8772ac50 100644 --- a/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.js +++ b/awx/ui/src/screens/Job/WorkflowOutput/WorkflowOutputToolbar.js @@ -49,6 +49,12 @@ const Badge = styled(PFBadge)` margin-left: 10px; `; +const ElapsedBadge = styled(Badge)` + margin-right: 20px; + min-width: 70px; + font-variant-numeric: tabular-nums; +`; + const ActionButton = styled(Button)` border: none; margin: 0px 6px; @@ -126,11 +132,11 @@ function WorkflowOutputToolbar({ job }) { )}
{t`Elapsed`}
- + {job.finished && job.elapsed != null ? secondsToHHMMSS(job.elapsed) : activeJobElapsedTime} - +
{t`Total Nodes`}
{totalNodes} From b73578733e17f38a12fa676e4ab322725a68e086 Mon Sep 17 00:00:00 2001 From: blaipr Date: Fri, 12 Jun 2026 15:38:18 +0200 Subject: [PATCH 4/4] Revert accidental lockfile churn (again) --- awx/ui/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/package-lock.json b/awx/ui/package-lock.json index 343db965f..820b0f582 100644 --- a/awx/ui/package-lock.json +++ b/awx/ui/package-lock.json @@ -23760,7 +23760,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "peer": true, "bin": {