diff --git a/libs/domains/service-logs/feature/src/lib/list-deployment-logs/filters-stage-step/filters-stage-step.spec.tsx b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/filters-stage-step/filters-stage-step.spec.tsx index f0ab9a5d7e0..df133328c1f 100644 --- a/libs/domains/service-logs/feature/src/lib/list-deployment-logs/filters-stage-step/filters-stage-step.spec.tsx +++ b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/filters-stage-step/filters-stage-step.spec.tsx @@ -1,6 +1,6 @@ import { StateEnum, type Status } from 'qovery-typescript-axios' import { type Terraform } from '@qovery/domains/services/data-access' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { act, renderWithProviders, screen } from '@qovery/shared/util-tests' import { FiltersStageStep, type FiltersStageStepProps } from './filters-stage-step' const mockToggleColumnFilter = jest.fn() @@ -27,6 +27,11 @@ jest.mock('@tanstack/react-router', () => ({ describe('FiltersStageStep', () => { beforeEach(() => { jest.clearAllMocks() + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() }) it('renders BUILD and DEPLOY buttons', () => { @@ -65,4 +70,58 @@ describe('FiltersStageStep', () => { expect(screen.getByText('1m : 0s')).toBeInTheDocument() // BUILD duration expect(screen.getByText('2m : 0s')).toBeInTheDocument() // DEPLOY duration }) + + it('ticks the current step duration locally from its backend start time', () => { + jest.setSystemTime(new Date('2026-08-25T10:05:00Z')) + const props = { + ...defaultProps, + serviceStatus: { + ...defaultProps.serviceStatus, + steps: { + details: [ + { step_name: 'GIT_CLONE', status: 'SUCCESS', duration_sec: 60 }, + { + step_name: 'BUILD', + status: 'ONGOING', + duration_sec: 0, + started_at: '2026-08-25T10:00:00Z', + }, + { step_name: 'DEPLOYMENT', status: 'SUCCESS', duration_sec: 120 }, + ], + }, + }, + } + + renderWithProviders() + + expect(screen.getByText('6m : 0s')).toBeInTheDocument() + + act(() => jest.advanceTimersByTime(1_000)) + + expect(screen.getByText('6m : 1s')).toBeInTheDocument() + }) + + it('uses the recorded duration once a step is completed', () => { + jest.setSystemTime(new Date('2026-08-25T11:00:00Z')) + const props = { + ...defaultProps, + serviceStatus: { + ...defaultProps.serviceStatus, + steps: { + details: [ + { + step_name: 'BUILD', + status: 'SUCCESS', + duration_sec: 300, + started_at: '2026-08-25T10:00:00Z', + }, + ], + }, + }, + } + + renderWithProviders() + + expect(screen.getByText('5m : 0s')).toBeInTheDocument() + }) }) diff --git a/libs/domains/service-logs/feature/src/lib/list-deployment-logs/filters-stage-step/filters-stage-step.tsx b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/filters-stage-step/filters-stage-step.tsx index 2ea5589d2bb..d3c9196656b 100644 --- a/libs/domains/service-logs/feature/src/lib/list-deployment-logs/filters-stage-step/filters-stage-step.tsx +++ b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/filters-stage-step/filters-stage-step.tsx @@ -9,7 +9,18 @@ import { Icon, StatusChip, Tooltip } from '@qovery/shared/ui' import { twMerge, upperCaseFirstLetter } from '@qovery/shared/util-js' import { type FilterType } from '../list-deployment-logs' -type StepMetricType = { build: ServiceStepMetric[]; deploy: ServiceStepMetric[]; executing: ServiceStepMetric[] } +type StepMetricType = { + build: ServiceStepMetric[] + deploy: ServiceStepMetric[] + executing: ServiceStepMetric[] +} + +const getStepDurationSec = (step: ServiceStepMetric, nowMs: number) => { + if (step.status !== 'ONGOING' || !step.started_at) return step.duration_sec || 0 + + const startedAtMs = Date.parse(step.started_at) + return Number.isFinite(startedAtMs) ? Math.max(0, Math.floor((nowMs - startedAtMs) / 1_000)) : 0 +} interface StageStepProps { type: Extract @@ -21,7 +32,12 @@ interface StageStepProps { function StageStep({ type, state, steps, toggleColumnFilter, isFilterActive }: StageStepProps) { const { hash } = useLocation() - const totalDurationSec = steps.reduce((acc, step) => acc + (step.duration_sec || 0), 0) + const nowMs = Date.now() + const totalDurationSec = steps.reduce((acc, step) => acc + getStepDurationSec(step, nowMs), 0) + const hasLiveDuration = steps.some( + (step) => + step.status === 'ONGOING' && Boolean(step.started_at) && Number.isFinite(Date.parse(step.started_at ?? '')) + ) const buildStep = steps.find((s) => s.step_name === 'BUILD') const deployStep = steps.find((s) => s.step_name === 'DEPLOYMENT') @@ -29,16 +45,16 @@ function StageStep({ type, state, steps, toggleColumnFilter, isFilterActive }: S const status = match({ type, state, buildStep, deployStep }) .with({ type: 'BUILD' }, () => { - if (state === 'BUILDING') return 'BUILDING' + if (state === 'BUILDING' || hasLiveDuration) return 'BUILDING' return buildStep?.status }) .with({ type: 'DEPLOY' }, () => { if (state === 'BUILDING') return 'READY' - if (state === 'DEPLOYING') return 'DEPLOYING' + if (state === 'DEPLOYING' || hasLiveDuration) return 'DEPLOYING' return deployStep?.status }) .with({ type: 'EXECUTING' }, () => { - if (state === 'EXECUTING') return 'EXECUTING' + if (state === 'EXECUTING' || hasLiveDuration) return 'EXECUTING' return executingStep?.status }) .exhaustive() @@ -58,16 +74,28 @@ function StageStep({ type, state, steps, toggleColumnFilter, isFilterActive }: S } // On the first load, if status is 'ERROR', the column filter is toggled // For all subsequent renders, the column filter is toggled only if the status is 'ERROR' - }, [status, toggleColumnFilter, isFirstLoad, hash]) + }, [status, toggleColumnFilter, isFirstLoad, hash, type]) + + const isStepRunning = + (type === 'BUILD' && status === 'BUILDING') || + (type === 'DEPLOY' && status === 'DEPLOYING') || + (type === 'EXECUTING' && status === 'EXECUTING') + const [, setTick] = useState(0) + + useEffect(() => { + if (!hasLiveDuration) return + + const intervalId = window.setInterval(() => setTick((tick) => tick + 1), 1_000) + return () => window.clearInterval(intervalId) + }, [hasLiveDuration]) - const isBuildingOrDeploying = - (type === 'BUILD' && status === 'BUILDING') || (type === 'DEPLOY' && status === 'DEPLOYING') + const shouldDisplayDuration = hasLiveDuration || totalDurationSec > 0 const buttonClasses = clsx( 'flex h-8 items-center gap-1.5 rounded-lg border border-neutral bg-surface-neutral px-2.5 text-sm font-medium text-neutral-subtle transition hover:border-neutral-subtle hover:bg-surface-neutral-component', { 'border-neutral-strong bg-surface-neutral-subtle text-neutral': isFilterActive(type), - 'border-brand-component bg-surface-brand-subtle': isBuildingOrDeploying && isFilterActive(type), + 'border-brand-component bg-surface-brand-subtle': isStepRunning && isFilterActive(type), 'border-positive-strong bg-surface-positive-subtle': status === 'SUCCESS' && isFilterActive(type), 'border-negative-strong bg-surface-negative-subtle': status === 'ERROR' && isFilterActive(type), } @@ -77,7 +105,7 @@ function StageStep({ type, state, steps, toggleColumnFilter, isFilterActive }: S