mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 08:02:19 +00:00
fix(frontend): show wall-clock execution time for workflow-as-code jobs
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { displayDate, msToReadableTime } from '$lib/utils'
|
||||
import { displayDate, jobDisplayDurationMs, msToReadableTime } from '$lib/utils'
|
||||
import type { CompletedJob, QueuedJob } from '$lib/gen'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import { forLater } from '$lib/forLater'
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
{#if job && 'success' in job && job.success}
|
||||
<Badge {large} color="green">
|
||||
Successfully ran in {msToReadableTime(job.duration_ms)}
|
||||
Successfully ran in {msToReadableTime(jobDisplayDurationMs(job))}
|
||||
{job.is_skipped ? '(Skipped)' : ''}
|
||||
{#if job.self_wait_time_ms || job.aggregate_wait_time_ms}
|
||||
<WaitTimeWarning
|
||||
@@ -34,7 +34,7 @@
|
||||
<Badge {large} color="orange" title={job.resolution_note}>
|
||||
<!-- `resolved_by` is absent both for an automatic resolution and for a manual one
|
||||
outside EE, so the automatic wording comes from `resolved_automatically`. -->
|
||||
Failed after {msToReadableTime(job.duration_ms)}, resolved{job.resolved_automatically
|
||||
Failed after {msToReadableTime(jobDisplayDurationMs(job))}, resolved{job.resolved_automatically
|
||||
? ' automatically'
|
||||
: job.resolved_by
|
||||
? ` by ${job.resolved_by}`
|
||||
@@ -49,7 +49,7 @@
|
||||
</Badge>
|
||||
{:else if job && 'success' in job}
|
||||
<Badge {large} color="red">
|
||||
Failed after {msToReadableTime(job.duration_ms)}
|
||||
Failed after {msToReadableTime(jobDisplayDurationMs(job))}
|
||||
{#if job.self_wait_time_ms || job.aggregate_wait_time_ms}
|
||||
<WaitTimeWarning
|
||||
self_wait_time_ms={job.self_wait_time_ms}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { Badge } from '../common'
|
||||
import { forLater } from '$lib/forLater'
|
||||
import DurationMs from '../DurationMs.svelte'
|
||||
import { jobDisplayDurationMs } from '$lib/utils'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
@@ -29,6 +30,7 @@
|
||||
let { id, children, class: clazz }: Props = $props()
|
||||
|
||||
let job: Job | undefined = $state(undefined)
|
||||
let displayDurationMs = $derived(jobDisplayDurationMs(job))
|
||||
let hovered = $state(false)
|
||||
let timeout: number | undefined
|
||||
let result: any = $state()
|
||||
@@ -155,9 +157,9 @@
|
||||
<Badge>
|
||||
Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'}
|
||||
</Badge>
|
||||
{#if job?.['duration_ms']}
|
||||
{#if displayDurationMs}
|
||||
<DurationMs
|
||||
duration_ms={job?.['duration_ms']}
|
||||
duration_ms={displayDurationMs}
|
||||
self_wait_time_ms={job?.self_wait_time_ms}
|
||||
aggregate_wait_time_ms={job?.aggregate_wait_time_ms}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Job } from '$lib/gen'
|
||||
import { triggerIconMap } from '$lib/components/triggers/utils'
|
||||
import { formatMemory } from '$lib/utils'
|
||||
import { formatMemory, jobDisplayDurationMs } from '$lib/utils'
|
||||
import { flowPathToHref } from '$lib/scripts'
|
||||
import { Calendar, Bot } from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
@@ -332,8 +332,8 @@ export const fieldConfigs: Record<JobField, FieldConfig> = {
|
||||
field: 'duration',
|
||||
label: 'Duration',
|
||||
getValue: (job) => {
|
||||
if ('duration_ms' in job && job.duration_ms) {
|
||||
const ms = job.duration_ms
|
||||
const ms = jobDisplayDurationMs(job)
|
||||
if (ms) {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
return `${(ms / 60000).toFixed(1)}m`
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
getQueryStmtCountHeuristic,
|
||||
isJobResolvable,
|
||||
parseDbInputFromAssetSyntax,
|
||||
apiErrorMessage
|
||||
apiErrorMessage,
|
||||
jobDisplayDurationMs
|
||||
} from './utils'
|
||||
|
||||
// Mirrors the backend invariant that only `status = 'failure'` rows can carry a
|
||||
@@ -598,3 +599,51 @@ describe('apiErrorMessage', () => {
|
||||
expect(apiErrorMessage(new Error('boom'))).toBe('boom')
|
||||
})
|
||||
})
|
||||
|
||||
// AI-agent jobs populate `workflow_as_code_status` too, so keying off the column
|
||||
// alone would swap their duration for wall-clock and count queue-free idle time.
|
||||
describe('jobDisplayDurationMs', () => {
|
||||
const wac = { workflow_as_code_status: { _checkpoint: { completed_steps: {} } } }
|
||||
|
||||
it('reports wall-clock for a workflow-as-code job', () => {
|
||||
expect(
|
||||
jobDisplayDurationMs({
|
||||
...wac,
|
||||
started_at: '2026-08-03T15:21:04.863Z',
|
||||
completed_at: '2026-08-03T15:21:19.612Z',
|
||||
duration_ms: 2058
|
||||
})
|
||||
).toBe(14749)
|
||||
})
|
||||
|
||||
it('keeps duration_ms for an AI-agent job and a plain script', () => {
|
||||
expect(
|
||||
jobDisplayDurationMs({
|
||||
workflow_as_code_status: { 'step-1': { name: 'tool' } },
|
||||
started_at: '2026-08-03T15:21:04.863Z',
|
||||
completed_at: '2026-08-03T15:21:19.612Z',
|
||||
duration_ms: 2058
|
||||
})
|
||||
).toBe(2058)
|
||||
expect(
|
||||
jobDisplayDurationMs({
|
||||
started_at: '2026-08-03T15:21:00.809Z',
|
||||
completed_at: '2026-08-03T15:21:04.858Z',
|
||||
duration_ms: 4046
|
||||
})
|
||||
).toBe(4046)
|
||||
})
|
||||
|
||||
it('falls back to duration_ms when the timestamps are missing or inverted', () => {
|
||||
expect(jobDisplayDurationMs({ ...wac, started_at: '2026-08-03T15:21:04.863Z', duration_ms: 7 }))
|
||||
.toBe(7)
|
||||
expect(
|
||||
jobDisplayDurationMs({
|
||||
...wac,
|
||||
started_at: '2026-08-03T15:21:19.612Z',
|
||||
completed_at: '2026-08-03T15:21:04.863Z',
|
||||
duration_ms: 7
|
||||
})
|
||||
).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -253,6 +253,47 @@ export function msToReadableTime(ms: number | undefined, maximumFractionDigits?:
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A job ran through the workflow-as-code (WAC) executor iff its
|
||||
* `workflow_as_code_status` carries a `_checkpoint`. AI-agent jobs populate the
|
||||
* same column but never write `_checkpoint`, so they must not match here.
|
||||
*/
|
||||
export function isWorkflowAsCodeJob(
|
||||
job: { workflow_as_code_status?: unknown } | undefined
|
||||
): boolean {
|
||||
const wac = job?.workflow_as_code_status as Record<string, unknown> | undefined
|
||||
return wac != undefined && wac['_checkpoint'] != undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Total execution time to display for a job, in ms.
|
||||
*
|
||||
* A WAC job suspends while the task jobs it dispatches run, so its `duration_ms`
|
||||
* covers only the orchestration script's own compute, not the end-to-end run. For
|
||||
* those, show the wall-clock span (`completed_at - started_at`) — the same total a
|
||||
* flow reports. `duration_ms` must keep counting compute only: cloud usage
|
||||
* accounting and workspace fairness consume it as service time.
|
||||
*/
|
||||
export function jobDisplayDurationMs(
|
||||
job:
|
||||
| {
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
duration_ms?: number
|
||||
workflow_as_code_status?: unknown
|
||||
}
|
||||
| undefined
|
||||
): number | undefined {
|
||||
if (isWorkflowAsCodeJob(job) && job?.started_at && job?.completed_at) {
|
||||
const start = new Date(job.started_at).getTime()
|
||||
const end = new Date(job.completed_at).getTime()
|
||||
if (isFinite(start) && isFinite(end) && end >= start) {
|
||||
return end - start
|
||||
}
|
||||
}
|
||||
return job?.duration_ms
|
||||
}
|
||||
|
||||
export function msToReadableTimeShort(
|
||||
ms: number | undefined,
|
||||
maximumFractionDigits?: number
|
||||
|
||||
Reference in New Issue
Block a user