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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/finetune-status.jsonPrompt
Build a React + TypeScript + Tailwind "FinetuneStatus" component with zod and
lucide-react. It is ONE fine-tune run rendered as a card: where it is in the
pipeline, how far through the schedule, what the loss is doing, what it was
launched with, which checkpoints exist — plus the two actions that matter, stop
it and ship it. The loss chart is hand-drawn SVG; no charting library.
Contract
- A zod schema (`finetuneJobSchema` in a sibling contract file) is the single
source of truth for one run: { id, baseModel, suffix?, status, progress,
loss[], hyperparams, checkpoints[], stoppedStage?, resultModel?,
trainedTokens?, error? }.
- `status` is SIX values: "validating" | "queued" | "training" | "succeeded" |
"failed" | "cancelled". Cancelled is a first-class outcome, not "failed with a
nicer word": the user asked for it, nothing is broken, and the checkpoints
written before the stop are still deployable.
- `progress` is { step, totalSteps, epoch, totalEpochs } — RAW COUNTERS, never a
pre-computed percentage. Epochs are fractional (1.8 of 3) because that is the
unit humans reason about; steps are the unit that moves often enough to prove
the job is alive.
- `loss` is [{ step, train, validation?: number | null }]. `validation` is null
on most points by design — an eval pass is expensive and runs every N steps.
- `checkpoints` is [{ id, step, epoch?, validationLoss?, model? }]. `model` is
the deployable id for THAT snapshot, which is what makes selection meaningful.
- `hyperparams` is Record<string, string | number | boolean> — scalars only,
rendered in declaration order.
- `stoppedStage` ("validating" | "queued" | "training") is the stage a failed or
cancelled run died inside. Keeping it is what turns "Failed" into "failed
while training"; a dataset that never parsed and a run that OOM'd at step 300
need completely different next actions.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
card's own render state. `status="error"` means the STATUS ENDPOINT failed,
which must not be worded like the training run failing.
- Props: job: Job | null; status; selectedCheckpointId? (controlled, `null` =
nothing selected); defaultSelectedCheckpointId?; onSelectCheckpoint?(id,
checkpoint); onCancel?(jobId); onUseModel?(modelId); onReload?;
maxCheckpoints? (4, clamped >= 1); hyperparamLabels?; formatLoss?;
emptyState?; errorMessage?; label? ("Fine-tune run"); className plus the rest
of the element props, ref forwarded to the <section>.
Behavior
- THE BAR IS DERIVED, NEVER TAKEN. fraction = clamp(step / totalSteps, 0, 1),
computed in the component, with three deliberate overrides: "succeeded" pins
it to 1 (a run the backend called complete must not sit at 97% while the word
says Succeeded); "validating" and "queued" render INDETERMINATE, because
nothing has run and a 0% bar claims a measurement that doesn't exist; a
totalSteps of 0 or a non-finite value is indeterminate too instead of dividing
by zero. The displayed counts are never clamped — a resumed run really can sit
at "step 500 of 480", and hiding that turns a confusing truth into a lie.
Indeterminate means role="progressbar" WITHOUT aria-valuenow, plus an
aria-valuetext that spells out the counts.
- THE PIPELINE'S FOURTH NODE IS AN OUTCOME SLOT, not a fifth stage. While the
run is alive it reads "Result · not started"; when the run ends it becomes
Succeeded / Failed / Cancelled. A stopped run marks `stoppedStage` red (or
muted, for a cancel), leaves earlier stages done, and marks later stages
"never reached" — so the strip answers "how far did we get", not "is it red".
When `stoppedStage` is absent, infer it: any step taken or any loss sample
means it reached training, otherwise it died on the dataset.
- THE VALIDATION LINE BREAKS AT ITS GAPS. Split the samples into runs at every
null and draw one path per run; a run of a single sample becomes a DOT,
because a one-point path renders nothing and would silently lose a
measurement. Never interpolate across a gap: a straight segment drawn through
a hole is a number nobody measured, and reading overfitting off an invented
line is exactly the mistake this chart exists to prevent.
- THE CHART IS SCRUBBABLE BY POINTER AND BY KEYBOARD. The plot frame is a
focusable role="group" with an aria-label summarising the curve; pointer move
picks the nearest sample in PIXEL space, ArrowLeft/ArrowRight step through
samples, Home/End jump to the ends, Escape clears the crosshair, blur and
pointer-leave clear it too. The visible readout line under the chart is itself
the aria-live="polite" region — one string for everyone, instead of a hover-
only tooltip half the audience can't reach. The lowest held-out loss is drawn
as a dashed horizontal rule: it turns "the curve wiggles" into "everything
right of here is worse than step N".
- Geometry is MEASURED with a ResizeObserver (disconnected on unmount), not
faked with preserveAspectRatio="none": non-uniform scaling would squash the
crosshair dots into ellipses and thin the stroke on wide cards. Keep a
fallback width for the first paint and for SSR. Non-finite samples are dropped
while preparing; if that leaves zero samples, say so instead of drawing an
empty box. Compute min/max in a LOOP — Math.min(...points) on a 20k-sample
curve is a stack overflow, and that is exactly the run you want to inspect.
- CHECKPOINTS ARE A RADIOGROUP, not a list of links: role="radiogroup" with
role="radio" rows, roving tabindex (tabIndex 0 on the checked row, or on the
first row when nothing is checked), Arrow keys moving selection with wrap-
around, Home/End, and Space/Enter from the native button. Selection is
controllable (`selectedCheckpointId`, where `null` is a real value meaning
"nothing selected") and falls back to internal state.
- SELECTION RE-TARGETS THE DEPLOY ACTION. "Use this model" ships the selected
checkpoint's `model`, falling back to `job.resultModel`. That coupling is the
point of the list: the best checkpoint is usually NOT the last one, because
held-out loss ticks back up once the run starts memorising. Mark the lowest
finite `validationLoss` as "best", ties going to the earlier step (same loss,
fewer GPU minutes). Sort newest-first; cap the list at `maxCheckpoints` with
an explicit "Show all N checkpoints" — and if the selected row would fall
outside the cap, expand automatically: a selection you can't see is a
selection you can't change.
- CANCEL IS ONE-SHOT AND CONFIRMED INLINE. The affordance only exists while the
run is stoppable and only when `onCancel` is passed. Clicking it swaps in a
destructive confirm that names the cost ("everything after the step 160
checkpoint is lost, and the GPU hours already spent are still billed"), with
"Keep training" as the way out. The lock lives in a REF read and written
synchronously inside the handler — five clicks dispatched in a single task all
read the same stale state, so a state-only guard would fire four extra cancel
requests. The lock is keyed to a generation that ticks whenever the card
enters a stoppable run, so it expires by itself instead of leaving a dead
button behind. Both buttons use aria-disabled, never the native `disabled`,
which would blur the button mid-action and drop it out of the tab order.
- Identity changes reset the card (adjust state during render, no effect): a new
`job.id` restores the default selection and re-collapses the checkpoint list,
and any status change closes an open confirm, so a recycled card can never
cancel the run the user was looking at a moment ago.
- The failure text is the one thing never capped: no max-height, no line clamp,
wrapped. The tail of a training error is usually the part that names the cause
(OOM, a bad row, a shape mismatch).
- With no `onUseModel` handler the model id renders as TEXT, not as a button. An
action that does nothing is worse than no action.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
bg-primary for the progress fill and the selected radio, bg-primary/5 with
border-primary for the current stage and the selected checkpoint,
text-destructive with bg-destructive/5 for the failure block, bg-destructive +
text-background for the confirm, var(--chart-1) for training loss and
var(--chart-2) for validation loss, with the area fill mixed via color-mix
from --chart-1. No hard-coded colours anywhere.
- States are SHAPES as well as colours — check / spinner / hourglass / dashed
circle / alert / ban — and every stage chip carries an sr-only word ("done",
"failed here", "never reached"), so the strip survives a monochrome theme and
a colour-blind reader. Queued shows an hourglass, not a spinner: a spinner
there would claim the GPU is already burning money.
- The root is a <section aria-label="Fine-tune run: <model>"> holding ONE
persistent sr-only role="status" line ("llama-3.1-8b-instruct support-tone-v3
is training, 44% of the schedule, 212 of 480 steps"). It is mounted for the
whole ready branch, so a status CHANGE is announced from an element the screen
reader is already watching.
- Long identifiers use `wrap-anywhere` plus `min-w-0`, not `truncate`: the job
id is the string people paste into a support ticket, so it is never allowed to
go missing.
- Every animation is motion-reduce-guarded: the stage spinner, the indeterminate
pulse, the skeleton, and the bar's width transition. Nothing about reading the
run depends on motion.
- cn() merges the consumer's className into the root, which also spreads the
remaining element props and forwards its ref.
Customization levers
- Density: `maxCheckpoints` decides how many snapshot rows are drawn before the
explicit reveal (1 for a compact list row, 20 for a debugging surface) — never
make the cap silent, the count is part of the reading.
- Sections: the card is a stack of independent blocks (header, stage strip,
progress, failure, loss, hyperparameters, checkpoints, actions). Drop the loss
plot for a compact list item, or drop hyperparameters when the same page
already shows the launch config.
- Wording: `hyperparamLabels` overrides the auto-humanised keys (`lora_r` →
"LoRA rank"), `formatLoss` swaps the metric (perplexity, nats, a percentage),
`label` names the region, `emptyState` replaces the empty body,
`errorMessage` + `onReload` own the envelope failure.
- Actions: pass only the handlers you can honour. No `onCancel` means no stop
affordance, no `onUseModel` means the model id renders as plain text, no
`onSelectCheckpoint` still lets the card own selection internally.
- Chart: the plot height, the x/y insets and the stroke weights are the knobs
for density; swap --chart-1 / --chart-2 for the pair your theme uses for
"primary series" and "reference series", and raise the dashed best-loss rule's
contrast if your surface is dense.
- Pipeline: the three stages are named in one constant. An RLHF or distillation
pipeline renames them ("Preparing → Scheduling → Optimising") without touching
the arithmetic, as long as it stays three ordered stages plus one outcome.Concepts
- Progress is composed, not accepted — the card divides steps by total steps itself and overrides the result in the three places arithmetic lies: a succeeded run is pinned to 100%, a run that hasn't started is indeterminate rather than 0%, and a missing denominator becomes indeterminate instead of NaN. A backend that hands you a
percentfield can only ever disagree with the counts printed next to it. - A failure keeps the stage it died at — the pipeline's fourth node is an outcome slot, so "Failed" can stay attached to "while training" with the earlier stages still marked done and the later ones marked "never reached". A dataset that never parsed and a run that OOM'd at step 300 are the same red word and completely different next actions.
- Gaps in the validation line stay gaps — evals run every N steps, so the held-out series is a set of runs, not a line. Interpolating across a hole draws a measurement nobody took, and the whole reason to look at this chart is to catch the moment held-out loss turns back up while training loss keeps falling.
- The best checkpoint is usually not the last one — once a run starts memorising, held-out loss climbs while training loss keeps dropping, so the snapshot worth shipping sits somewhere in the middle. The list marks it, and selecting any row re-targets the deploy action, which is what makes the list a decision instead of a display.
- One-shot cancel, keyed to a generation — the guard is a ref read and written synchronously in the click handler, because five clicks dispatched in one task all observe the same stale state. The generation ticks whenever the card enters a stoppable run, so the lock expires by itself instead of leaving a dead button behind after the next job loads.
- The readout is the live region — the same string that appears under the chart is what a screen reader announces while the arrow keys walk the curve, so scrubbing is one behaviour with one output rather than a hover tooltip plus a separate accessible fallback that drifts out of sync.
Logprobs Viewer
Token-level probabilities for a model response — the answer itself heat-tinted by uncertainty on one chart token, dotted-underlined below a live threshold, with top-k alternatives as probability bars on hover, focus or arrow key.
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.