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