From 366bdf7c72af150c8d28fd9b1ab35d95d553d305 Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Fri, 7 Aug 2026 09:14:21 -0700 Subject: [PATCH] feat(dashboard): surface per-attempt job runs and frozen progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foreman keeps outcome per attempt: job.result holds only the winning attempt's payload, and current_run is non-null exclusively while an attempt holds the lease. So a failed attempt's error, result, and progress had nowhere to surface — including the partial-failure payloads handlers now attach to a failure. Add an Attempts card to the job details page, fetching /jobs/:id/runs and rendering one collapsible row per attempt with its worker, duration, progress, error, and result. Anything that didn't succeed starts expanded, as does the latest attempt; toggles are keyed by run id so a new attempt landing over SSE doesn't reopen what was collapsed. Switch the jobs list and the details progress/worker fields onto last_run, so pending-after-retry and terminal rows keep their final reading instead of showing an em dash. This makes progressBarClass's pending and terminal branches reachable for the first time — they were dead code, written against a last-run fallback that current_run never provided. Moved to lib/job-stream so both views share it. Requires foreman's include=last_run (Gaucho-Racing/Foreman#1). --- dashboard/src/components/jobs/JobRunsCard.tsx | 202 ++++++++++++++++++ dashboard/src/lib/job-stream.ts | 16 ++ dashboard/src/models/job.tsx | 6 + dashboard/src/pages/jobs/JobDetailsPage.tsx | 34 ++- dashboard/src/pages/jobs/JobsPage.tsx | 35 ++- 5 files changed, 261 insertions(+), 32 deletions(-) create mode 100644 dashboard/src/components/jobs/JobRunsCard.tsx diff --git a/dashboard/src/components/jobs/JobRunsCard.tsx b/dashboard/src/components/jobs/JobRunsCard.tsx new file mode 100644 index 00000000..f95963c0 --- /dev/null +++ b/dashboard/src/components/jobs/JobRunsCard.tsx @@ -0,0 +1,202 @@ +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { JobStatusBadge } from "@/components/jobs/JobStatusBadge"; +import { BACKEND_URL } from "@/consts/config"; +import { http, getAxiosErrorMessage } from "@/lib/http"; +import { formatDurationMs, useTickingNow } from "@/lib/job-stream"; +import { Run } from "@/models/job"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +// JobRunsCard renders every attempt at a job, newest first. +// +// Foreman keeps per-attempt outcome on the Run, not the Job: job.result is +// reserved for the winning attempt and job.current_run is null once the job +// terminalizes. So a failed attempt's error and result — including partial +// -failure payloads from handlers that return bytes alongside an error — are +// only reachable through /jobs/:id/runs. Without this card they're invisible. +export function JobRunsCard({ + jobId, + attemptCount, + status, +}: { + jobId: string; + attemptCount: number; + status: string; +}) { + const [runs, setRuns] = useState(null); + const [error, setError] = useState(null); + // User toggles win over the per-run default; keyed by run id so a refetch + // (new attempt landing over SSE) doesn't reopen what the user collapsed. + const [overrides, setOverrides] = useState>({}); + + const fetchRuns = useCallback(async () => { + try { + const r = await http.get(`${BACKEND_URL}/foreman/jobs/${jobId}/runs`); + setRuns((r.data.data as Run[]) ?? []); + setError(null); + } catch (e) { + setError(getAxiosErrorMessage(e)); + } + }, [jobId]); + + // attemptCount / status come off the SSE-backed job, so a new attempt or a + // terminal transition pulls the fresh run list without a poll of our own. + useEffect(() => { + void fetchRuns(); + }, [fetchRuns, attemptCount, status]); + + if (error) { + return ( + +

Attempts

+ +
{error}
+
+ ); + } + + if (!runs || runs.length === 0) { + return ( + +

Attempts

+ +
+ {runs ? "No attempts yet." : "Loading attempts…"} +
+
+ ); + } + + // Foreman returns attempt ASC; newest-first reads better here. + const ordered = [...runs].sort((a, b) => b.attempt - a.attempt); + const latest = ordered[0].attempt; + + return ( + +

Attempts ({runs.length})

+ +
+ {ordered.map((run, i) => ( + + setOverrides((prev) => ({ + ...prev, + [run.id]: !(prev[run.id] ?? defaultExpanded(run, latest)), + })) + } + /> + ))} +
+
+ ); +} + +// Anything that didn't succeed is why you opened this card, so it starts +// open — as does the latest attempt, which is the usual first thing read. +function defaultExpanded(run: Run, latestAttempt: number): boolean { + return run.status !== "succeeded" || run.attempt === latestAttempt; +} + +function RunRow({ + run, + first, + expanded, + onToggle, +}: { + run: Run; + first: boolean; + expanded: boolean; + onToggle: () => void; +}) { + const running = run.status === "running"; + const now = useTickingNow(500, running); + const started = new Date(run.started_at).getTime(); + const end = run.finished_at ? new Date(run.finished_at).getTime() : now; + const duration = isNaN(started) ? -1 : end - started; + + const hasResult = run.result && Object.keys(run.result).length > 0; + const hasProgress = run.progress_total > 0; + + return ( +
+ + + {expanded && ( +
+
+
Run id
+
{run.id}
+
Started
+
{fmtTime(run.started_at)}
+
Finished
+
{fmtTime(run.finished_at)}
+
+ + {hasProgress && ( +
+ progress {run.progress_current.toLocaleString()} /{" "} + {run.progress_total.toLocaleString()} + {run.progress_message ? ` — ${run.progress_message}` : ""} +
+ )} + + {run.error && ( +
+
Error
+
+                {run.error}
+              
+
+ )} + + {hasResult && ( +
+
Result
+
+                {JSON.stringify(run.result, null, 2)}
+              
+
+ )} + + {!run.error && !hasResult && !hasProgress && ( +
+ No error, result, or progress reported for this attempt. +
+ )} +
+ )} +
+ ); +} + +function fmtTime(s?: string): string { + if (!s) return "—"; + const d = new Date(s); + if (isNaN(d.getTime())) return "—"; + return d.toLocaleString(); +} diff --git a/dashboard/src/lib/job-stream.ts b/dashboard/src/lib/job-stream.ts index cd13fb4c..b42883a4 100644 --- a/dashboard/src/lib/job-stream.ts +++ b/dashboard/src/lib/job-stream.ts @@ -91,6 +91,22 @@ export function formatCount(n: number): string { export const PROGRESS_GRADIENT_CLASS = "bg-gradient-to-r from-gr-pink to-gr-purple"; +// progressBarClass picks the indicator colour from job status: +// - active → GR brand gradient (live) +// - pending → neutral gray (progress carried from a previous attempt +// that got reaped or failed; waiting to be re-claimed) +// - terminal → white (frozen final reading) +// +// The non-active branches only render at all when the caller reads +// last_run rather than current_run: current_run is non-null exclusively +// while an attempt holds the lease, so a current_run-only view has no +// progress to colour once the job stops. +export function progressBarClass(status: string): string { + if (status === "active") return PROGRESS_GRADIENT_CLASS; + if (status === "pending") return "bg-neutral-500"; + return "bg-white"; +} + export function formatDurationMs(ms: number): string { if (ms < 0) return "—"; if (ms < 1000) return `${ms}ms`; diff --git a/dashboard/src/models/job.tsx b/dashboard/src/models/job.tsx index adfb4bc1..1fdab706 100644 --- a/dashboard/src/models/job.tsx +++ b/dashboard/src/models/job.tsx @@ -64,6 +64,12 @@ export interface Job { // run has finished). Workers reading these calls in code, not from // the dashboard, will see this as `null` and should ignore it. current_run?: Run | null; + // Populated by ?include=last_run and on every SSE event. The newest + // attempt whatever its status, so unlike current_run it survives the + // attempt finishing — this is what carries progress, error, and result + // for pending-after-retry and terminal jobs. Null only when the job has + // never been claimed. For an active job it IS the in-flight run. + last_run?: Run | null; } export const initJob: Job = { diff --git a/dashboard/src/pages/jobs/JobDetailsPage.tsx b/dashboard/src/pages/jobs/JobDetailsPage.tsx index 7bbb6372..857fe0c7 100644 --- a/dashboard/src/pages/jobs/JobDetailsPage.tsx +++ b/dashboard/src/pages/jobs/JobDetailsPage.tsx @@ -4,11 +4,12 @@ import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Progress } from "@/components/ui/progress"; import { JobStatusBadge } from "@/components/jobs/JobStatusBadge"; +import { JobRunsCard } from "@/components/jobs/JobRunsCard"; import { LoadingComponent } from "@/components/Loading"; import { elapsedMs, formatDurationMs, - PROGRESS_GRADIENT_CLASS, + progressBarClass, useJobStream, useTickingNow, } from "@/lib/job-stream"; @@ -57,6 +58,8 @@ function JobDetailsPage() { )} + {/* job.result is the winning attempt's payload only. Per-attempt + results and errors live on the Runs below. */} {job.result && Object.keys(job.result).length > 0 && ( )} @@ -70,6 +73,12 @@ function JobDetailsPage() { )} + + ); @@ -116,9 +125,11 @@ function JobHeader({ job, active }: { job: Job; active: boolean }) { function OverviewCard({ job, active }: { job: Job; active: boolean }) { const now = useTickingNow(500, active); - // Worker + lease live on the in-flight Run. Pending jobs (no claim - // yet) and terminal jobs (run finished, current_run null) both - // display "—". + // Worker comes off last_run so a finished job still names who ran the + // final attempt. Lease stays on current_run — only an in-flight run + // holds one, and it's nulled the moment the attempt closes. Jobs that + // were never claimed have neither and display "—". + const lastRun = job.last_run ?? job.current_run; const run = job.current_run; return ( @@ -130,7 +141,7 @@ function OverviewCard({ job, active }: { job: Job; active: boolean }) { ["Service", job.service || "—"], ["Priority", String(job.priority)], ["Attempt", `${job.attempt_count} / ${job.max_attempts}`], - ["Worker", run?.worker_id || "—"], + ["Worker", lastRun?.worker_id || "—"], ["Idempotency key", job.idempotency_key || "—"], ["Scheduled", fmtTime(job.scheduled_at)], ["Enqueued", fmtTime(job.enqueued_at)], @@ -148,10 +159,10 @@ function OverviewCard({ job, active }: { job: Job; active: boolean }) { } function ProgressCard({ job }: { job: Job }) { - // Progress comes off the in-flight Run. Once the job terminalizes, - // current_run goes null — fine to show "No progress reported" then, - // since the result / error cards below carry the outcome. - const run = job.current_run; + // Prefer last_run so a finished job keeps its final reading; current_run + // is the fallback for a Foreman that predates the last_run include, where + // it's the only run on the event and active jobs still work. + const run = job.last_run ?? job.current_run; const total = run?.progress_total ?? 0; const current = run?.progress_current ?? 0; const pct = total > 0 ? (current / total) * 100 : 0; @@ -161,7 +172,10 @@ function ProgressCard({ job }: { job: Job }) { {total > 0 ? (
- +
{current.toLocaleString()} / {total.toLocaleString()} diff --git a/dashboard/src/pages/jobs/JobsPage.tsx b/dashboard/src/pages/jobs/JobsPage.tsx index df774e91..06ee8809 100644 --- a/dashboard/src/pages/jobs/JobsPage.tsx +++ b/dashboard/src/pages/jobs/JobsPage.tsx @@ -25,7 +25,7 @@ import { formatDurationMs, formatCount, elapsedMs, - PROGRESS_GRADIENT_CLASS, + progressBarClass, } from "@/lib/job-stream"; import { Job, JOB_STATUSES, isTerminalStatus } from "@/models/job"; import { EnqueueJobDialog } from "@/components/jobs/EnqueueJobDialog"; @@ -67,10 +67,11 @@ function JobsPage() { const params = new URLSearchParams(); params.set("limit", String(PAGE_SIZE)); // Foreman v2: progress/worker/lease moved off Job onto the - // per-attempt Run. include=current_run folds the in-flight run - // into each row so the table can render progress without a - // second fetch per row. - params.set("include", "current_run"); + // per-attempt Run. include=last_run folds each job's newest run + // into its row so the table renders progress without a second + // fetch per row — and unlike current_run it stays populated after + // the attempt finishes, so pending and terminal rows keep theirs. + params.set("include", "last_run"); if (status) params.set("status", status); if (kind.trim()) params.set("kind", kind.trim()); if (serviceName.trim()) params.set("service", serviceName.trim()); @@ -177,8 +178,7 @@ function JobsPage() { /> )} - {runningJobs.length > 0 && - (status === "" || status === "active") && ( + {runningJobs.length > 0 && (status === "" || status === "active") && (

Running ({runningJobs.length}) @@ -318,10 +318,12 @@ function FilterBar(props: FilterBarProps) { } function JobRow({ job, onClick }: { job: Job; onClick: () => void }) { - // Progress lives on the in-flight Run (or the last run's snapshot, - // when included). Pending jobs that never claimed have no run yet — - // show "—" rather than a stale 0/0 bar. - const run = job.current_run; + // Progress comes off last_run, not current_run: current_run is non-null + // only while an attempt holds the lease, which would leave every pending + // and terminal row blank. last_run is the same run for an active job and + // the final reading once it stops. Jobs never claimed have no run at all + // — show "—" rather than a stale 0/0 bar. + const run = job.last_run; const total = run?.progress_total ?? 0; const current = run?.progress_current ?? 0; const pct = total > 0 ? (current / total) * 100 : 0; @@ -361,15 +363,4 @@ function JobRow({ job, onClick }: { job: Job; onClick: () => void }) { ); } -// progressBarClass picks the indicator colour from job status: -// - running → GR brand gradient (live) -// - pending → neutral gray (progress carried from a previous attempt -// that got reaped or failed; waiting to be re-claimed) -// - terminal → white (frozen final reading) -function progressBarClass(status: string): string { - if (status === "active") return PROGRESS_GRADIENT_CLASS; - if (status === "pending") return "bg-neutral-500"; - return "bg-white"; -} - export default JobsPage;