Scroll the runs table, pick a range, and choose what a comparison plots against
Docs / docs (push) Successful in 35s
Playwright Tests / test-playwright (1, 2) (push) Failing after 3m14s
Playwright Tests / test-playwright (2, 2) (push) Failing after 1m44s
pre-commit / pre-commit (push) Failing after 3m54s
Test Backend / test-backend (push) Successful in 3m12s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Failing after 1m28s

This commit is contained in:
2026-08-25 16:32:34 +02:00
parent 3c15964364
commit 4350916bd8
14 changed files with 498 additions and 151 deletions
+5
View File
@@ -2622,6 +2622,11 @@ export const SeriesAnswerSchema = {
type: 'string',
title: 'Metric'
},
x: {
type: 'string',
title: 'X',
default: 'step'
},
lines: {
items: {
'$ref': '#/components/schemas/MetricSeries'
+6 -1
View File
@@ -1899,9 +1899,13 @@ export class RunsService {
* This is the comparison view: it answers in the same shape a flow answers a
* chart's query with, so putting three training curves beside each other is
* a widget binding rather than a screen of its own.
*
* ``x`` names what to plot against — nothing or "step", "time", or another
* metric of the same runs.
* @param data The data for the request.
* @param data.ids
* @param data.metric
* @param data.x
* @returns SeriesAnswer Successful Response
* @throws ApiError
*/
@@ -1911,7 +1915,8 @@ export class RunsService {
url: '/api/v1/runs/series/compare',
query: {
ids: data.ids,
metric: data.metric
metric: data.metric,
x: data.x
},
errors: {
422: 'Validation Error'
+2
View File
@@ -929,6 +929,7 @@ export type SecretValue = {
export type SeriesAnswer = {
metric: string;
x?: string;
lines?: Array<MetricSeries>;
};
@@ -1611,6 +1612,7 @@ export type RunsReadMetricsResponse = (Array<MetricPoint>);
export type RunsCompareMetricData = {
ids: string;
metric: string;
x?: string;
};
export type RunsCompareMetricResponse = (SeriesAnswer);
@@ -13,6 +13,7 @@ import {
compareQueryOptions,
NO_CURVE,
runsListQueryOptions,
STEP_AXIS,
shortenRunLabel,
} from "@/components/Runs/queries"
import { type DataContext, useDataContext } from "./dataContext"
@@ -155,7 +156,7 @@ function RunsChart({ widget }: WidgetProps) {
: (found.data ?? []).slice(0, MAX_SERIES).map((run) => run.id)
const { data, isPending } = useQuery(
compareQueryOptions(ids, metric, refreshMs),
compareQueryOptions(ids, metric, STEP_AXIS, refreshMs),
)
const palette = usePalette()
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
+58 -5
View File
@@ -9,11 +9,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import {
compareQueryOptions,
NO_CURVE,
runMetricsQueryOptions,
STEP_AXIS,
shortenRunLabel,
TIME_AXIS,
} from "./queries"
/** The metrics one run recorded, in the order they are worth offering. */
@@ -28,32 +31,41 @@ export function useMetricNames(runId: string | undefined) {
}
/**
* One metric across one or more runs, drawn on a step axis.
* One metric across one or more runs.
*
* The endpoint answers in the chart widget's own series shape, so comparing
* three curves and showing one are the same call with a different id list.
*
* `x` is what the readings are plotted against: the step they were recorded
* at, the seconds since the run's own first one, or another metric of the same
* runs. Never a clock — even "time" is an elapsed count, which is what makes
* two runs started hours apart comparable.
*/
export function RunMetricChart({
ids,
metric,
x = STEP_AXIS,
refreshMs,
className,
}: {
ids: string[]
metric: string
x?: string
refreshMs?: number
className?: string
}) {
const { data, isPending } = useQuery(
compareQueryOptions(ids, metric, refreshMs),
compareQueryOptions(ids, metric, x, refreshMs),
)
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
const labels = lines.map((line) => shortenRunLabel(line.label))
const plots: HistoryPoint[][] = lines.map((line) =>
(line.points ?? []).map(([step, value]) => ({ ts: step, value })),
(line.points ?? []).map(([at, value]) => ({ ts: at, value })),
)
const drawn = plots.reduce((total, plot) => total + plot.length, 0)
return (
<div className="flex h-64 flex-col gap-2">
<div className={cn("flex min-h-0 flex-col gap-2", className ?? "h-64")}>
<UplotChart
labels={labels}
plots={plots}
@@ -84,7 +96,7 @@ export function MetricPicker({
if (names.length === 0) return null
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 w-56">
<SelectTrigger className="h-8 w-56" aria-label="Metric">
<SelectValue placeholder="Metric" />
</SelectTrigger>
<SelectContent>
@@ -97,3 +109,44 @@ export function MetricPicker({
</Select>
)
}
/**
* What the comparison is plotted against.
*
* Doubles as the axis label — it sits directly above the chart, and naming the
* axis twice is one caption too many.
*/
export function AxisPicker({
names,
metric,
value,
onChange,
}: {
names: string[]
/** Excluded from the choices: a metric against itself is a straight line. */
metric: string
value: string
onChange: (x: string) => void
}) {
const options = [
{ value: STEP_AXIS, label: "vs step" },
{ value: TIME_AXIS, label: "vs time (s)" },
...names
.filter((name) => name !== metric)
.map((name) => ({ value: name, label: `vs ${name}` })),
]
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 w-44" aria-label="Plotted against">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
+36 -18
View File
@@ -4,6 +4,7 @@ import { ChevronDown, ChevronRight, Download } from "lucide-react"
import { useState } from "react"
import type { ArtifactRow, RunNodeRow } from "@/client"
import { ValuePreview } from "@/components/Flow/ValuePreview"
import { ago } from "@/components/Health/queries"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
@@ -121,15 +122,7 @@ export function RunDetail({ id }: { id: string }) {
) : (
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(run.params).map(([key, value]) => (
<div
key={key}
className="flex justify-between gap-4 border-border border-b py-1"
>
<dt className={LABEL}>{key}</dt>
<dd className="truncate font-mono text-sm">
{paramText(value)}
</dd>
</div>
<Entry key={key} name={key} value={value} />
))}
</dl>
)}
@@ -154,15 +147,7 @@ export function RunDetail({ id }: { id: string }) {
<h2 className="font-medium text-sm">Result</h2>
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(run.result ?? {}).map(([key, value]) => (
<div
key={key}
className="flex justify-between gap-4 border-border border-b py-1"
>
<dt className={LABEL}>{key}</dt>
<dd className="truncate font-mono text-sm">
{paramText(value)}
</dd>
</div>
<Entry key={key} name={key} value={value} />
))}
</dl>
</section>
@@ -175,6 +160,39 @@ export function RunDetail({ id }: { id: string }) {
)
}
/**
* One named value: read directly when it is a scalar, unfolded when it is not.
*
* The same disclosure a node's ports use on the canvas a run's report is a
* record like any other, and serialising it onto one truncated line answers
* nothing.
*/
function Entry({ name, value }: { name: string; value: unknown }) {
const structured = value !== null && typeof value === "object"
return (
<div
className={cn(
"gap-4 border-border border-b py-1",
structured ? "flex flex-col" : "flex justify-between",
)}
>
<dt className={LABEL}>{name}</dt>
<dd
className={cn(
"min-w-0",
structured ? "" : "truncate font-mono text-sm",
)}
>
{structured ? (
<ValuePreview value={value} defaultOpen />
) : (
paramText(value)
)}
</dd>
</div>
)
}
function Fact({
label,
children,
+233 -111
View File
@@ -1,9 +1,11 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { FlaskConical, X } from "lucide-react"
import { useRef } from "react"
import type { fluksio__api__routes__runs__RunRow as RunRow } from "@/client"
import { MAX_SERIES } from "@/components/Common/UplotChart"
import { ValuePreview } from "@/components/Flow/ValuePreview"
import { ago } from "@/components/Health/queries"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@@ -24,16 +26,22 @@ import {
TableRow,
} from "@/components/ui/table"
import { cn, dur } from "@/lib/utils"
import { MetricPicker, RunMetricChart, useMetricNames } from "./MetricChart"
import {
AxisPicker,
MetricPicker,
RunMetricChart,
useMetricNames,
} from "./MetricChart"
import { OpenInDashboard } from "./OpenInDashboard"
import {
CARD,
LIST_CAP,
paramsSummary,
MAX_SELECTION,
paramText,
runOverviewQueryOptions,
runsInfiniteQueryOptions,
STATUSES,
STEP_AXIS,
shortCommit,
shortId,
varyingKeys,
@@ -47,6 +55,8 @@ export type RunsSearch = {
/** The runs being compared, comma-joined — a comparison is a link. */
compare?: string
metric?: string
/** What the comparison is plotted against: a step, a time, or a metric. */
x?: string
}
export function RunsScreen({
@@ -68,11 +78,38 @@ export function RunsScreen({
const runs: RunRow[] = data?.pages.flat() ?? []
const selected = search.compare ? search.compare.split(",") : []
const toggle = (id: string) => {
const next = selected.includes(id)
? selected.filter((one) => one !== id)
: [...selected, id]
update({ compare: next.length ? next.join(",") : undefined })
const put = (next: string[]) =>
update({
compare: next.length ? next.slice(0, MAX_SELECTION).join(",") : undefined,
})
// Where a shift-click measures from: the row last picked on its own. Held in
// a ref rather than the address — it is how the last click was made, not
// part of what the link says.
const anchor = useRef<number | null>(null)
const select = (index: number, shift: boolean) => {
const id = runs[index]?.id
if (!id) return
if (shift && anchor.current !== null && anchor.current !== index) {
const [from, to] = [anchor.current, index].sort((a, b) => a - b)
const span = runs.slice(from, to + 1).map((run) => run.id)
put([...selected, ...span.filter((one) => !selected.includes(one))])
return
}
anchor.current = index
put(
selected.includes(id)
? selected.filter((one) => one !== id)
: [...selected, id],
)
}
const selectAll = () => {
const shown = runs.map((run) => run.id)
const already = shown.every((id) => selected.includes(id))
anchor.current = null
put(already ? [] : shown)
}
return (
@@ -130,7 +167,9 @@ export function RunsScreen({
runs={runs}
search={search}
selected={selected}
onToggle={toggle}
compact={selected.length > 0}
onSelect={select}
onSelectAll={selectAll}
onGroup={(group) => update({ group, compare: undefined })}
/>
)}
@@ -148,6 +187,10 @@ export function RunsScreen({
)}
<p className="text-muted-foreground text-xs">
{runs.length} run{runs.length === 1 ? "" : "s"}
{selected.length > 0 ? `, ${selected.length} picked` : ""}
{selected.length >= MAX_SELECTION
? `${MAX_SELECTION} is as many as one comparison carries`
: ""}
{!hasNextPage && runs.length >= LIST_CAP
? ` — the newest ${LIST_CAP}, which is as deep as this list reads`
: ""}
@@ -238,130 +281,187 @@ function FlowRail({
)
}
/** One parameter: read directly when it is a scalar, unfolded when it is not. */
function ParamValue({ value }: { value: unknown }) {
if (value !== null && typeof value === "object") {
return <ValuePreview value={value} className="max-w-56" />
}
return <span className="font-mono text-xs">{paramText(value)}</span>
}
function RunsTable({
runs,
search,
selected,
onToggle,
compact,
onSelect,
onSelectAll,
onGroup,
}: {
runs: RunRow[]
search: RunsSearch
selected: string[]
onToggle: (id: string) => void
/** A comparison is open below, so the table gives up some of its height. */
compact: boolean
onSelect: (index: number, shift: boolean) => void
onSelectAll: () => void
onGroup: (group: string) => void
}) {
// Under a sweep filter the shared parameters say nothing; the two or three
// that were swept are the whole point, so they get columns of their own.
const varying = search.group ? varyingKeys(runs).slice(0, 4) : []
const showFlow = !search.flow
const columns = 6 + (showFlow ? 1 : 0) + Math.max(1, varying.length)
// Whether a click carried shift, read where the event still has it: Radix
// hands `onCheckedChange` the new state and nothing else.
const shift = useRef(false)
const remember = (event: { shiftKey: boolean }) => {
shift.current = event.shiftKey
}
const picked = runs.filter((run) => selected.includes(run.id)).length
const all = runs.length > 0 && picked === runs.length
return (
<div className={cn(CARD, "overflow-x-auto p-0")}>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-8" />
<TableHead>Run</TableHead>
{showFlow && <TableHead>Flow</TableHead>}
<TableHead>Status</TableHead>
{varying.length > 0 ? (
varying.map((key) => <TableHead key={key}>{key}</TableHead>)
) : (
<TableHead>Parameters</TableHead>
)}
<TableHead>Seed</TableHead>
<TableHead>Code</TableHead>
<TableHead className="text-right">Took</TableHead>
<TableHead>Started</TableHead>
<TableHead>By</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.length === 0 && (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={10}
className="h-24 text-center text-muted-foreground"
>
No runs match this filter.
</TableCell>
</TableRow>
<Table
containerClassName={cn(
"overflow-y-auto",
// Bounded so the page does not scroll away from the comparison drawn
// under it: the table is the thing with a scrollbar, not the screen.
// While one is open the room left over is what the chart does not
// need, which is a height rather than a share of the window — a short
// laptop screen has the same chart on it as a tall one.
compact ? "max-h-[calc(100svh-33rem)] min-h-40" : "max-h-[72svh]",
)}
>
<TableHeader className="sticky top-0 z-10 bg-muted">
<TableRow className="hover:bg-transparent">
<TableHead className="w-8">
<Checkbox
checked={all ? true : picked > 0 ? "indeterminate" : false}
onCheckedChange={onSelectAll}
aria-label={
all ? "Clear the selection" : "Select every run shown"
}
data-testid="run-select-all"
/>
</TableHead>
<TableHead>Run</TableHead>
{showFlow && <TableHead>Flow</TableHead>}
<TableHead>Status</TableHead>
{varying.length > 0 ? (
varying.map((key) => <TableHead key={key}>{key}</TableHead>)
) : (
<TableHead>Parameters</TableHead>
)}
{runs.map((run) => (
<TableRow key={run.id} data-testid="run-row">
<TableCell>
<Checkbox
checked={selected.includes(run.id)}
onCheckedChange={() => onToggle(run.id)}
aria-label={`Compare ${run.id}`}
data-testid="run-select"
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Link
to="/runs/$id"
params={{ id: run.id }}
className="font-mono text-sm hover:underline"
data-testid="run-link"
<TableHead>Seed</TableHead>
<TableHead>Code</TableHead>
<TableHead>Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.length === 0 && (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns}
className="h-24 text-center text-muted-foreground"
>
No runs match this filter.
</TableCell>
</TableRow>
)}
{runs.map((run, index) => (
<TableRow key={run.id} data-testid="run-row">
<TableCell>
<Checkbox
checked={selected.includes(run.id)}
onPointerDown={remember}
onKeyDown={remember}
onCheckedChange={() => onSelect(index, shift.current)}
aria-label={`Compare ${run.id}`}
data-testid="run-select"
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Link
to="/runs/$id"
params={{ id: run.id }}
className="font-mono text-sm hover:underline"
data-testid="run-link"
>
{shortId(run.id)}
</Link>
{run.group_id && !search.group && (
<button
type="button"
onClick={() => onGroup(run.group_id as string)}
className="rounded-full border border-border px-1.5 text-muted-foreground text-xs hover:bg-accent"
>
{shortId(run.id)}
</Link>
{run.group_id && !search.group && (
<button
type="button"
onClick={() => onGroup(run.group_id as string)}
className="rounded-full border border-border px-1.5 text-muted-foreground text-xs hover:bg-accent"
>
sweep
</button>
)}
</div>
</TableCell>
{showFlow && (
<TableCell className="text-muted-foreground text-sm">
{run.flow}
</TableCell>
)}
<TableCell>
<RunStatusBadge run={run} />
</TableCell>
{varying.length > 0 ? (
varying.map((key) => (
<TableCell key={key} className="font-mono text-sm">
{paramText(run.params[key])}
</TableCell>
))
) : (
<TableCell className="max-w-64 truncate font-mono text-muted-foreground text-xs">
{paramsSummary(run.params) || "—"}
</TableCell>
)}
<TableCell className="text-muted-foreground text-sm tabular-nums">
{run.seed ?? "—"}
</TableCell>
<TableCell className="font-mono text-muted-foreground text-xs">
{/* The user's own repository when there is one: for a flow
declared in code, the store's commit names a generated
shim rather than anything anyone wrote. A canvas flow has
only the store's, which is then the whole answer. */}
{shortCommit(run.origin_commit || run.commit || "") || "—"}
</TableCell>
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
{run.duration_ms ? dur(run.duration_ms) : "—"}
</TableCell>
sweep
</button>
)}
</div>
</TableCell>
{showFlow && (
<TableCell className="text-muted-foreground text-sm">
{ago(String(run.created_at ?? ""))}
{run.flow}
</TableCell>
<TableCell className="max-w-40 truncate text-muted-foreground text-sm">
{run.actor || "—"}
)}
<TableCell>
<RunStatusBadge run={run} />
</TableCell>
{varying.length > 0 ? (
varying.map((key) => (
<TableCell key={key}>
<ParamValue value={run.params[key]} />
</TableCell>
))
) : (
<TableCell>
{Object.keys(run.params).length === 0 ? (
<span className="text-muted-foreground text-xs"></span>
) : (
<div className="flex max-w-96 flex-wrap items-baseline gap-x-3 gap-y-0.5">
{Object.entries(run.params).map(([key, value]) => (
<span
key={key}
className="flex min-w-0 items-baseline gap-1 whitespace-nowrap"
>
<span className="font-mono text-muted-foreground text-xs">
{key}
</span>
<ParamValue value={value} />
</span>
))}
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<TableCell className="text-muted-foreground text-sm tabular-nums">
{run.seed ?? "—"}
</TableCell>
<TableCell className="font-mono text-muted-foreground text-xs">
{/* The user's own repository when there is one: for a flow
declared in code, the store's commit names a generated
shim rather than anything anyone wrote. A canvas flow has
only the store's, which is then the whole answer. */}
{shortCommit(run.origin_commit || run.commit || "") || "—"}
</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground text-sm">
{/* When it started, and how long it then took. Two readings of
one thing, so one column rather than two. */}
{ago(String(run.created_at ?? ""))}
<span className="px-1.5 text-border">|</span>
<span className="tabular-nums">
{run.duration_ms ? dur(run.duration_ms) : "—"}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
@@ -382,6 +482,13 @@ function Compare({
search.metric && names.includes(search.metric)
? search.metric
: (names[0] ?? "")
// An axis naming a metric these runs stopped recording falls back to the
// step, which every run has.
const x =
search.x &&
(search.x === STEP_AXIS || search.x === "time" || names.includes(search.x))
? search.x
: STEP_AXIS
const tooMany = ids.length > MAX_SERIES
return (
@@ -395,6 +502,16 @@ function Compare({
value={metric}
onChange={(name) => update({ metric: name })}
/>
{names.length > 0 && (
<AxisPicker
names={names}
metric={metric}
value={x}
onChange={(next) =>
update({ x: next === STEP_AXIS ? undefined : next })
}
/>
)}
<OpenInDashboard flow={flow} ids={ids} />
<Button
variant="ghost"
@@ -414,7 +531,12 @@ function Compare({
)}
{metric ? (
<RunMetricChart ids={ids.slice(0, MAX_SERIES)} metric={metric} />
<RunMetricChart
ids={ids.slice(0, MAX_SERIES)}
metric={metric}
x={x}
className="h-56"
/>
) : (
<p className="text-muted-foreground text-sm">
These runs recorded no metric series to compare.
+18 -4
View File
@@ -27,8 +27,8 @@ export const runKeys = {
detail: (id: string) => ["runs", "detail", id] as const,
metrics: (id: string, name: string) =>
["runs", "detail", id, "metrics", name] as const,
compare: (ids: string[], metric: string) =>
["runs", "compare", ids.join(","), metric] as const,
compare: (ids: string[], metric: string, x: string) =>
["runs", "compare", ids.join(","), metric, x] as const,
}
/** The resting surface these screens are built from, as Health names it. */
@@ -93,17 +93,31 @@ export const runMetricsQueryOptions = (id: string, name = "") => ({
queryFn: () => RunsService.readMetrics({ runId: id, name }),
})
/** What a comparison can be plotted against, beside another metric's name. */
export const STEP_AXIS = "step"
export const TIME_AXIS = "time"
export const compareQueryOptions = (
ids: string[],
metric: string,
x = STEP_AXIS,
refetchInterval?: number,
) => ({
queryKey: runKeys.compare(ids, metric),
queryFn: () => RunsService.compareMetric({ ids: ids.join(","), metric }),
queryKey: runKeys.compare(ids, metric, x),
queryFn: () => RunsService.compareMetric({ ids: ids.join(","), metric, x }),
enabled: ids.length > 0 && Boolean(metric),
...(refetchInterval ? { refetchInterval } : {}),
})
/**
* How many runs one selection carries.
*
* The selection is the address, which is what makes a comparison a link and
* a URL is not a place to put five hundred 22-character ids. A chart draws
* five of them anyway; the rest of the room is for changing your mind.
*/
export const MAX_SELECTION = 50
export function useCancelRun() {
const client = useQueryClient()
return useMutation({
+14 -2
View File
@@ -2,11 +2,23 @@ import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
function Table({
className,
containerClassName,
...props
}: React.ComponentProps<"table"> & {
/** For the scroll container rather than the table a bounded height here is
* what lets a long table scroll inside the page instead of with it, and it
* is the box a sticky header sticks to. */
containerClassName?: string
}) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto rounded-lg border"
className={cn(
"relative w-full overflow-x-auto rounded-lg border",
containerClassName,
)}
>
<table
data-slot="table"
@@ -17,6 +17,7 @@ export const Route = createFileRoute("/_layout/runs/")({
group: text(search.group),
compare: text(search.compare),
metric: text(search.metric),
x: text(search.x),
}),
head: () => ({ meta: [{ title: "Runs - Fluksio" }] }),
})