diff --git a/awx/ui/src/screens/Job/JobOutput/shared/OutputToolbar.js b/awx/ui/src/screens/Job/JobOutput/shared/OutputToolbar.js
index f9bd12805..3df5cea9f 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,
@@ -35,27 +35,17 @@ 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;
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',
@@ -151,13 +141,11 @@ const OutputToolbar = ({ job, onDelete, isDeleteDisabled, jobStatus }) => {
{t`Elapsed`}
-
- {job.finished
- ? Duration.fromObject({ seconds: job.elapsed }).toFormat(
- 'hh:mm:ss'
- )
+
+ {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 52f32b38e..a8772ac50 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,
@@ -48,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;
@@ -71,6 +78,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 +130,14 @@ function WorkflowOutputToolbar({ job }) {
)}
+
{t`Elapsed`}
+
+
+ {job.finished && job.elapsed != null
+ ? 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..c96697b5f 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,19 @@ describe('WorkflowOutputToolbar', () => {
test('should render correct toolbar item', () => {
shouldFind(`Button[ouiaId="edit-workflow"]`);
shouldFind('Button#workflow-output-toggle-legend');
- shouldFind('Badge');
+ 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').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', () => {
@@ -109,4 +115,73 @@ 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 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',
+ 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');