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');