Skip to content
Merged
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
202 changes: 202 additions & 0 deletions dashboard/src/components/jobs/JobRunsCard.tsx
Original file line number Diff line number Diff line change
@@ -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<Run[] | null>(null);
const [error, setError] = useState<string | null>(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<Record<string, boolean>>({});

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]);
Comment on lines +45 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh the active attempt when heartbeat progress changes

While an attempt is running, heartbeat SSE events can change its progress and message without changing either attemptCount or status, so this effect never refetches and the expanded attempt row remains at the values captured when the card first loaded. On the same page the main progress card updates from the stream, producing contradictory readings until the attempt terminates or another attempt starts; pass the streamed run/update timestamp through or otherwise refresh on heartbeat updates.

Useful? React with 👍 / 👎.


if (error) {
return (
<Card className="p-4">
<h4 className="mb-2">Attempts</h4>
<Separator className="mb-3" />
<div className="text-sm text-red-200">{error}</div>
</Card>
);
}

if (!runs || runs.length === 0) {
return (
<Card className="p-4">
<h4 className="mb-2">Attempts</h4>
<Separator className="mb-3" />
<div className="text-sm text-muted-foreground">
{runs ? "No attempts yet." : "Loading attempts…"}
</div>
</Card>
);
}

// 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 (
<Card className="p-4">
<h4 className="mb-2">Attempts ({runs.length})</h4>
<Separator className="mb-3" />
<div className="flex flex-col">
{ordered.map((run, i) => (
<RunRow
key={run.id}
run={run}
first={i === 0}
expanded={overrides[run.id] ?? defaultExpanded(run, latest)}
onToggle={() =>
setOverrides((prev) => ({
...prev,
[run.id]: !(prev[run.id] ?? defaultExpanded(run, latest)),
}))
}
/>
))}
</div>
</Card>
);
}

// 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 (
<div className={first ? "" : "border-t border-neutral-800"}>
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-3 py-2 text-left hover:bg-neutral-900"
>
{expanded ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="w-8 shrink-0 font-mono text-xs text-muted-foreground">
#{run.attempt}
</span>
<JobStatusBadge status={run.status} />
<span className="truncate font-mono text-xs text-muted-foreground">
{run.worker_id || "—"}
</span>
<span className="ml-auto shrink-0 font-mono text-xs">
{formatDurationMs(duration)}
</span>
</button>

{expanded && (
<div className="flex flex-col gap-3 pb-3 pl-7 pr-1">
<dl className="grid grid-cols-[100px_1fr] gap-y-1 text-xs">
<dt className="text-muted-foreground">Run id</dt>
<dd className="break-all font-mono">{run.id}</dd>
<dt className="text-muted-foreground">Started</dt>
<dd className="font-mono">{fmtTime(run.started_at)}</dd>
<dt className="text-muted-foreground">Finished</dt>
<dd className="font-mono">{fmtTime(run.finished_at)}</dd>
</dl>

{hasProgress && (
<div className="text-xs text-muted-foreground">
progress {run.progress_current.toLocaleString()} /{" "}
{run.progress_total.toLocaleString()}
{run.progress_message ? ` — ${run.progress_message}` : ""}
</div>
)}

{run.error && (
<div>
<div className="mb-1 text-xs text-red-400">Error</div>
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-neutral-950 p-3 font-mono text-xs text-red-200">
{run.error}
</pre>
</div>
)}

{hasResult && (
<div>
<div className="mb-1 text-xs text-muted-foreground">Result</div>
<pre className="overflow-x-auto rounded bg-neutral-950 p-3 font-mono text-xs">
{JSON.stringify(run.result, null, 2)}
</pre>
</div>
)}

{!run.error && !hasResult && !hasProgress && (
<div className="text-xs text-muted-foreground">
No error, result, or progress reported for this attempt.
</div>
)}
</div>
)}
</div>
);
}

function fmtTime(s?: string): string {
if (!s) return "—";
const d = new Date(s);
if (isNaN(d.getTime())) return "—";
return d.toLocaleString();
}
16 changes: 16 additions & 0 deletions dashboard/src/lib/job-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
6 changes: 6 additions & 0 deletions dashboard/src/models/job.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
34 changes: 24 additions & 10 deletions dashboard/src/pages/jobs/JobDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -57,6 +58,8 @@ function JobDetailsPage() {
<JsonCard title="Params" data={job.params} />
)}

{/* 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 && (
<JsonCard title="Result" data={job.result} />
)}
Expand All @@ -70,6 +73,12 @@ function JobDetailsPage() {
</pre>
</Card>
)}

<JobRunsCard
jobId={job.id}
attemptCount={job.attempt_count}
status={job.status}
/>
</div>
</Layout>
);
Expand Down Expand Up @@ -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 (
<Card className="p-4">
Expand All @@ -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)],
Expand All @@ -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;
Expand All @@ -161,7 +172,10 @@ function ProgressCard({ job }: { job: Job }) {
<Separator className="mb-3" />
{total > 0 ? (
<div className="flex flex-col gap-2">
<Progress value={pct} indicatorClassName={PROGRESS_GRADIENT_CLASS} />
<Progress
value={pct}
indicatorClassName={progressBarClass(job.status)}
/>
<div className="flex justify-between text-sm text-muted-foreground">
<span>
{current.toLocaleString()} / {total.toLocaleString()}
Expand Down
35 changes: 13 additions & 22 deletions dashboard/src/pages/jobs/JobsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve current-run progress until Foreman is upgraded

In the checked docker-compose.yaml configuration, Foreman remains pinned to 2.0.2, whose repository-side client only supports include=current_run; the old server therefore returns bare jobs for this new value. Running the dashboard against the committed local stack makes job.last_run undefined and removes progress from every row, including active jobs. Upgrade the pinned Foreman deployment in lockstep or retain a compatible fallback before switching this query.

Useful? React with 👍 / 👎.

if (status) params.set("status", status);
if (kind.trim()) params.set("kind", kind.trim());
if (serviceName.trim()) params.set("service", serviceName.trim());
Expand Down Expand Up @@ -177,8 +178,7 @@ function JobsPage() {
/>
)}

{runningJobs.length > 0 &&
(status === "" || status === "active") && (
{runningJobs.length > 0 && (status === "" || status === "active") && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted-foreground">
Running ({runningJobs.length})
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Loading