Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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(<FiltersStageStep {...props} />)

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(<FiltersStageStep {...props} />)

expect(screen.getByText('5m : 0s')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<FilterType, 'BUILD' | 'DEPLOY' | 'EXECUTING'>
Expand All @@ -21,24 +32,29 @@ 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')
const executingStep = steps.find((s) => s.step_name === 'EXECUTING')

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()
Expand All @@ -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),
}
Expand All @@ -77,7 +105,7 @@ function StageStep({ type, state, steps, toggleColumnFilter, isFilterActive }: S
<button className={twMerge(buttonClasses)} onClick={() => toggleColumnFilter(type)}>
<StatusChip status={status} />
{upperCaseFirstLetter(type.toLowerCase())}
{totalDurationSec > 0 ? (
{shouldDisplayDuration ? (
<>
<svg xmlns="http://www.w3.org/2000/svg" width="5" height="6" fill="none" viewBox="0 0 5 6">
<circle cx="2.5" cy="3" r="2.5" fill="#383E50" />
Expand All @@ -91,18 +119,22 @@ function StageStep({ type, state, steps, toggleColumnFilter, isFilterActive }: S
content={
<span className="flex flex-col gap-0.5">
{steps.length > 0 ? (
steps.map((step) => (
<span key={step.step_name} className="font-medium">
{upperCaseFirstLetter(step.step_name)?.replace(/_/g, ' ')}:{' '}
{step.duration_sec ? (
<>
{Math.floor(step.duration_sec / 60)}m {step.duration_sec % 60}s
</>
) : (
'0s'
)}
</span>
))
steps.map((step, index) => {
const durationSec = getStepDurationSec(step, nowMs)

return (
<span key={`${step.step_name}-${index}`} className="font-medium">
{upperCaseFirstLetter(step.step_name)?.replace(/_/g, ' ')}:{' '}
{durationSec ? (
<>
{Math.floor(durationSec / 60)}m {durationSec % 60}s
</>
) : (
'0s'
)}
</span>
)
})
) : (
<span>No detail available</span>
)}
Expand Down Expand Up @@ -133,7 +165,7 @@ export function FiltersStageStep({
}: FiltersStageStepProps) {
if (!steps?.details) return <div />

const categorizedSteps = steps.details.reduce(
const categorizedSteps = steps.details.reduce<StepMetricType>(
(acc, step) => {
if (!step.step_name) return acc

Expand All @@ -145,7 +177,7 @@ export function FiltersStageStep({

return acc
},
{ build: [], deploy: [], executing: [] } as StepMetricType
{ build: [], deploy: [], executing: [] }
)

return (
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"mermaid": "11.6.0",
"monaco-editor": "0.53.0",
"posthog-js": "1.345.1",
"qovery-typescript-axios": "1.1.958",
"qovery-typescript-axios": "1.1.961",
"react": "18.3.1",
"react-country-flag": "3.0.2",
"react-datepicker": "4.12.0",
Expand Down
1 change: 1 addition & 0 deletions review
Submodule review added at 7d33ab
10 changes: 5 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6343,7 +6343,7 @@ __metadata:
prettier: 3.2.5
prettier-plugin-tailwindcss: 0.5.14
pretty-quick: 4.0.0
qovery-typescript-axios: 1.1.958
qovery-typescript-axios: 1.1.961
qovery-ws-typescript-axios: 0.1.644
react: 18.3.1
react-country-flag: 3.0.2
Expand Down Expand Up @@ -25863,12 +25863,12 @@ __metadata:
languageName: node
linkType: hard

"qovery-typescript-axios@npm:1.1.958":
version: 1.1.958
resolution: "qovery-typescript-axios@npm:1.1.958"
"qovery-typescript-axios@npm:1.1.961":
version: 1.1.961
resolution: "qovery-typescript-axios@npm:1.1.961"
dependencies:
axios: 1.18.1
checksum: 91757e4ceb42ef4ec005a4d6b97ee39ec77a44958f4225292b38ed78cdbd52da373fed9cc688608da967147af2246d1b508067d1580a172af2caf2a21d6f57df
checksum: 304958cafc8bcefab8357c0df2f6ebbc358234082cfe4a75bb6d76aeaedbd79e1b6e939cb9f1bfb7359af597ccb49c952400c0ae9678bef2ad642fb64d91fb1a
languageName: node
linkType: hard

Expand Down
Loading