Batch Jobs
A batch inference jobs table — middle-truncated copyable job ids, a stacked done/failed/pending bar per job, spend so far, age and ETA derived from an injected clock, an expandable failed-request preview, a one-shot cancel, partial-result downloads, and four data states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/batch-jobs.jsonPrompt
Build a React + TypeScript + Tailwind "BatchJobs" component with zod and
lucide-react, plus a small copy-to-clipboard hook (any implementation that
returns { copied, copy, error } and clears its own reset timer on unmount).
Contract
- A zod schema (`batchJobsItemSchema` in a sibling contract file) is the single
source of truth for ONE job: { id, status, total, done, failed, costUsd,
createdAt, eta?, model?, endpoint?, error?, failures?: { customId, code?,
httpStatus?, message }[] }.
- The job status union is SEVEN values, not three: "validating" | "running" |
"cancelling" | "completed" | "cancelled" | "failed" | "expired". The three most
tables drop are the three that decide what the reader does next:
`cancelling` is not `cancelled` (the stop was accepted, in-flight requests are
still landing, and the cancel affordance must already be gone); `expired` is not
`failed` (the completion window elapsed, nothing broke, and the partial output
is still downloadable and still billed); `cancelled` and `expired` therefore
both carry PARTIAL RESULTS, and collapsing them into "failed" tells people to
re-run work they already paid for.
- `failures` is a SAMPLE of failed requests, never the error file. `failed` is the
true count, and the panel always says "Showing 3 of 118".
- `costUsd` is money spent SO FAR, not a forecast — it keeps climbing on a running
job and survives on a cancelled one.
- `createdAt` / `eta` are INSTANTS (ISO string, epoch ms or Date), never
pre-baked "2h ago" strings, so the wording is recomputed against the clock you
inject rather than going stale mid-render.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
table's own render state, independent of any job's status. `status="error"`
means the batch API did not answer; a JOB failing is `job.status === "failed"`,
and the two read differently on screen.
- Props: items: Job[]; status; now? (injected clock); defaultOpenIds?;
onCancel?(jobId); onDownload?(jobId); onRetry?; failureSampleLimit? (3);
idHeadChars? (14); idTailChars? (6); formatCost?; formatDuration?;
formatAbsolute?; emptyState?; errorMessage?; showSummary? (true); label?
("Batch jobs"); className plus the rest of the element props, ref forwarded to
the <section>.
- Every numeric prop is clamped (failureSampleLimit >= 1, idHeadChars >= 4,
idTailChars >= 3) so a 0 or a NaN can never render an empty panel or an id
that is only an ellipsis.
Behavior
- THE DENOMINATOR IS DERIVED, NOT TRUSTED. Per row compute done/failed as
clamped non-negative integers (NaN, -1 and 3.7 all become >= 0), then
total = max(declaredTotal, done + failed) and pending = total - done - failed.
A provider that reports 2,807 processed requests for a 2,800-request file
(retries counted twice) widens the denominator instead of painting a bar
segment past the end of its track — an overflowing bar is a rendering bug the
reader blames on their own data. total === 0 must not divide by zero. A widened
denominator then EXPLAINS itself, in the figures ("of 2,807 (2,800 submitted)")
and in the bar's aria-valuetext: a total nobody submitted, shown without a word,
reads as a bug in the table.
- CANCEL IS ONE-SHOT. The handler fires at most once per job per status no matter
how fast the button is clicked, because the guard is a ref keyed `id -> status`
that is read and written synchronously inside the click handler: several clicks
dispatched in one task all read the same stale state, so a state-only guard
would let the extras through. The pending marker is keyed to the status it
fired FROM, which makes it expire by itself — the moment the consumer moves the
job on (running -> cancelling), the marker stops matching, the spinner lifts and
a genuinely new request is answerable again. No effect, no timer, no reset
callback. Document the trade this buys: if YOUR cancel call fails and you leave
the job in the same status, the button stays locked — re-arm it by moving the
job to a distinct status or by remounting the row, because "the reader already
asked for this exact thing" is the state the lock is modelling.
- The cancel button uses aria-disabled, never the native `disabled` attribute:
`disabled` blurs the button the instant it flips, dropping focus to <body> in
the middle of the action, and removes it from the tab order.
- ACTIONS ARE DERIVED FROM STATUS, never toggled by the click. Cancel is offered
only for "validating" / "running" and only when onCancel is passed; a
"cancelling" row shows a non-interactive "Stopping" marker instead. Download is
offered for any terminal status with done > 0 — labelled "Partial" for
cancelled / expired / failed, because a partial file is exactly what those
states leave behind — and is hidden entirely when nothing finished. A button
that calls nothing is worse than no button.
- The failure panel is a real disclosure: a <button aria-expanded> per row whose
panel row is UNMOUNTED when closed (a zero-height collapse leaves the panel's
controls reachable by Tab — an invisible keyboard trap), with aria-controls set
only while open so it never points at an id that is not in the document. The
chevron exists only when there is something to open (failed > 0, a sample, or a
job-level error); other rows render an equal-width spacer so the ids stay
aligned. Nothing auto-expands — a table that opens every failing row is a table
you must collapse before you can scan it — but `defaultOpenIds` pins chosen rows
open on mount and is deliberately read ONCE, so a poll that re-sends the same
list cannot slam a panel shut while it is being read.
- Truncation is always announced. The panel draws failureSampleLimit samples and
then states "Showing 3 of 118 failed requests — the error file has the rest";
when failed > 0 but no samples came with the job it says so instead of
rendering an empty panel. Per-request messages and the job-level error are the
one thing never capped: no max-height, no line clamp, wrapped.
- THE TABLE OWNS NO CLOCK. With `now` injected, createdAt renders as an age
("3h 12m ago") and eta as a remaining time ("ETA 22m"), both recomputed from the
instants every render and never accumulated, so a paused tab, a re-render or a
replayed poll cannot drift. Without `now`, both fall back to absolute UTC
instants — formatted with an explicit timeZone, because formatting in the
ambient zone renders one string on the server and another in the browser. An
ETA already in the past reads "ETA any moment", never a negative or a fake
"0s"; a terminal job's ETA is dropped entirely; an unparseable instant collapses
to "—" once, so no downstream branch has to think about NaN.
- Job ids are MIDDLE-truncated in JS, never with a CSS ellipsis: provider ids
share a long prefix and differ in the tail, so cutting the end turns every row
into the same string. The id itself is the copy button — the accessible name
carries the value in full, the click writes the full value, and a blocked
clipboard flips to a destructive icon instead of failing silently.
- The table never sorts, filters or paginates: it renders `items` in the order you
hand it over, and `status="ready"` with an empty array renders the empty branch
rather than a headed table with no rows.
- The horizontal scroll container becomes a labelled, tabbable region ONLY while
it is actually overflowing, measured through a ResizeObserver (subscribed via
useSyncExternalStore, so there is no effect-then-setState round trip) that
watches both the container and the table and is disconnected on unmount. A
scroll box a keyboard cannot reach is a WCAG failure; a permanent tab stop on a
table that fits is noise.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
bg-primary for the done segment and the Download button (with
text-primary-foreground), bg-destructive for the failed segment,
bg-destructive/5 + border-destructive/40 + text-destructive for failure blocks,
bg-muted for the bar track and skeletons, bg-muted/30 for the open panel row.
No hard-coded colours anywhere; the only inline styles are the two bar widths.
- Seven statuses are seven SHAPES plus a written word — dashed circle, spinner,
ban, check, x-circle, alert, hourglass — so they survive a monochrome theme and
a colour-blind reader, which is exactly where cancelled / failed / expired would
otherwise blur together.
- The stacked bar is one track: done and failed are drawn as widths over a muted
remainder. It is a role="progressbar" named by an aria-label ("Requests" — a
progressbar with no accessible name is announced as a bare "progress bar")
whose aria-valuetext carries the prose ("1,842 of 2,800 requests done, 12
failed, 946 pending"), while the visible figures next to it are aria-hidden —
"1,842 · 12 · 2,800" is a riddle when read aloud.
- Markup is a real <table> with a scoped <thead>, an sr-only <caption> repeating
the aggregate line, and one <tbody> row per job plus an optional detail row that
spans every column. The root is a <section aria-label> holding ONE persistent
sr-only role="status" line, so a copy or a cancel is announced from an element
the screen reader is already watching.
- Long ids and long provider messages use `wrap-anywhere` (overflow-wrap:
anywhere) plus `min-w-0`, NOT `break-words`: only the former lowers the
element's min-content width, which is what actually stops a 64-character id
from widening the whole table.
- Every animation is decorative and guarded: the running spinner, the cancelling
pulse, the skeleton shimmer and the bar's width transition all stop under
prefers-reduced-motion while the words keep saying what is happening.
- cn() merges the consumer's className into the root, which also spreads the
remaining element props and forwards its ref.
Customization levers
- Columns: `showSummary` drops the aggregate header line; deleting the Cost or
the Created cell (with its <th>) is a two-line change — keep the colSpan of the
detail row in step with the column count.
- Units: `formatCost` swaps the currency or the precision (a credits balance, a
per-1M-token figure); the default spells sub-cent spend as "<$0.01" instead of
the free-looking "$0.00". `formatDuration` swaps the age/ETA wording (the
default is deliberately coarse — batches live for hours, not milliseconds), and
`formatAbsolute` owns the no-clock fallback.
- Live time: pass a `now` you tick once per second in your app — one interval for
the whole page instead of one per row.
- Density: `idHeadChars` / `idTailChars` decide how much of an id survives
truncation; `failureSampleLimit` decides how much of the error file is previewed
before the "of N" note. Raise them in an ops console, lower them in a customer
dashboard — but never make either silent.
- Disclosure: `defaultOpenIds` pins rows open on first paint (the failing one you
linked someone to). Wire `onCancel` / `onDownload` to your API — omit either and
the matching affordance disappears rather than becoming decorative.
- Chrome: `label` names the region and titles the header, `emptyState` replaces
the empty body, `errorMessage` + `onRetry` own the envelope failure, and
`model` / `endpoint` are your own per-row scan aids.Concepts
- Partial results are the whole point — a cancelled or expired batch is not a failed one: it left a real output file behind and it was really billed. The row's action is derived from
done > 0rather than from "did it say completed", so the work you already paid for stays one click away instead of being re-run. - A self-expiring one-shot lock — the cancel guard is a ref read and written synchronously inside the click handler, keyed to the status it fired from. Several clicks dispatched in one task all observe the same stale state, so a state-only guard would send several cancels; keying it to the status means the lock lifts by itself the moment the consumer moves the job on, instead of leaving a dead button behind.
- The denominator is derived, not trusted — counts arrive from a worker that retries, double-counts and occasionally sends NaN. Clamping each count and taking
max(total, done + failed)means the bar can never overflow its track, and a zero-request job can never divide by zero; the widened total then names the figure it grew from, so the reader sees odd numbers that explain themselves rather than a broken widget. - Truncation is announced, in both directions — a failure preview says "Showing 3 of 118" so a sample is never mistaken for the total, and a job id is truncated in the MIDDLE because provider ids share a prefix and differ in the tail. A CSS ellipsis would render every row as the same string.
- No clock inside the table — ages and ETAs are recomputed from
createdAt/etaagainst an injectednow, never incremented by a timer the component owns. A paused tab, a re-render or a replayed poll therefore cannot drift, the server and the browser agree, and droppingnowdegrades to absolute instants rather than to a lie. - The scroll region earns its tab stop — a horizontally scrollable box that a keyboard cannot reach is a WCAG failure, so the region role and the tab stop appear only while the table actually overflows, measured by a ResizeObserver subscribed as an external store and disconnected on unmount.
Finetune Status
One fine-tune run as a card — a pipeline strip that keeps the stage a stopped run died at, derived step/epoch progress, a scrubbable train/validation loss sparkline with broken eval gaps, hyperparameter rows, a checkpoint radiogroup that re-targets the deploy action, and a one-shot cancel behind an inline destructive confirm.
Safety Controls
Per-category content thresholds on a four-rung scale — a plain-language line for what each rung actually does, strictness presets that batch-set every row, policy floors and locked rows, and an unsaved-changes bar that names the filters you are about to loosen.