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
+31 -6
View File
@@ -132,6 +132,9 @@ class MetricSeries(BaseModel):
class SeriesAnswer(BaseModel):
metric: str
#: What the x values are: "step", "time" (seconds since this run's first
#: reading), or the name of another metric this one was plotted against.
x: str = "step"
lines: list[MetricSeries] = Field(default_factory=list)
@@ -356,13 +359,38 @@ def read_metrics(
return rows
def _points(session: Session, run_id: str, metric: str, x: str) -> list[list[float]]:
"""One run's readings of ``metric``, against whichever x was asked for.
The step is the default because it is what every run has. Time answers
"which one got there sooner", and is measured from this run's own first
reading so that runs started hours apart still lie on top of each other.
Another metric answers "against what the loop was actually counting" — an
epoch, or samples seen — and is joined on the step the two share, which is
the only thing they have in common.
"""
rows = _series(session, run_id, metric)
if x == "time":
if not rows:
return []
start = min(row.ts for row in rows)
return [[row.ts - start, row.value] for row in rows]
if x and x != "step":
against = {row.step: row.value for row in _series(session, run_id, x)}
return [[against[row.step], row.value] for row in rows if row.step in against]
return [[float(row.step), row.value] for row in rows]
@router.get("/series/compare", response_model=SeriesAnswer)
def compare_metric(session: SessionDep, ids: str, metric: str) -> Any:
def compare_metric(session: SessionDep, ids: str, metric: str, x: str = "") -> Any:
"""One metric across several runs, as the chart widget's series shape.
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.
"""
run_ids = [part for part in ids.split(",") if part]
if not run_ids:
@@ -376,13 +404,10 @@ def compare_metric(session: SessionDep, ids: str, metric: str) -> Any:
run = runs.get(run_id)
if run is None:
continue
rows = _series(session, run_id, metric)
label = run_id
if run.seed is not None:
label = f"{run_id} (seed {run.seed})"
lines.append(
MetricSeries(
label=label, points=[[float(row.step), row.value] for row in rows]
)
MetricSeries(label=label, points=_points(session, run_id, metric, x))
)
return SeriesAnswer(metric=metric, lines=lines)
return SeriesAnswer(metric=metric, x=x or "step", lines=lines)
+83 -1
View File
@@ -8,7 +8,7 @@ import json
from datetime import UTC, datetime
import pytest
from sqlmodel import Session, select
from sqlmodel import Session, col, select
from fluksio.core.config import settings
from fluksio.core.db import engine as db_engine
@@ -380,3 +380,85 @@ def test_a_curve_whose_run_is_gone_is_empty_rather_than_an_error(
)
assert answer.status_code == 200
assert answer.json() == []
def _metric(run_id: str, name: str, step: int, value: float, ts: float) -> RunMetric:
return RunMetric(run_id=run_id, name=name, step=step, value=value, ts=ts)
@pytest.fixture
def plotted():
"""A run with a loss curve and an epoch counter beside it."""
run_id = new_run_id()
with Session(db_engine) as session:
session.add(
Run(id=run_id, flow="study", status="ok", created_at=datetime.now(UTC))
)
for step, (loss, epoch) in enumerate([(1.0, 10.0), (0.5, 20.0), (0.25, 30.0)]):
session.add(_metric(run_id, "study.loss", step, loss, 100.0 + step * 5))
session.add(_metric(run_id, "study.epoch", step, epoch, 100.0 + step * 5))
session.commit()
yield run_id
with Session(db_engine) as session:
for row in session.exec(
select(RunMetric).where(col(RunMetric.run_id) == run_id)
).all():
session.delete(row)
session.delete(session.get(Run, run_id))
session.commit()
def test_a_comparison_is_plotted_against_the_step_by_default(
client, superuser_token_headers, plotted
):
answer = client.get(
f"{settings.API_V1_STR}/runs/series/compare",
params={"ids": plotted, "metric": "study.loss"},
headers=superuser_token_headers,
).json()
assert answer["x"] == "step"
assert answer["lines"][0]["points"] == [[0.0, 1.0], [1.0, 0.5], [2.0, 0.25]]
def test_time_is_measured_from_this_runs_own_first_reading(
client, superuser_token_headers, plotted
):
"""Runs started hours apart still lie on top of each other."""
answer = client.get(
f"{settings.API_V1_STR}/runs/series/compare",
params={"ids": plotted, "metric": "study.loss", "x": "time"},
headers=superuser_token_headers,
).json()
assert answer["x"] == "time"
assert answer["lines"][0]["points"] == [[0.0, 1.0], [5.0, 0.5], [10.0, 0.25]]
def test_one_metric_can_be_plotted_against_another(
client, superuser_token_headers, plotted
):
answer = client.get(
f"{settings.API_V1_STR}/runs/series/compare",
params={"ids": plotted, "metric": "study.loss", "x": "study.epoch"},
headers=superuser_token_headers,
).json()
assert answer["lines"][0]["points"] == [[10.0, 1.0], [20.0, 0.5], [30.0, 0.25]]
def test_a_step_the_x_metric_never_reached_is_left_out(
client, superuser_token_headers, plotted
):
"""The join is on the step, which is the only thing two series share."""
with Session(db_engine) as session:
session.add(_metric(plotted, "study.loss", 3, 0.1, 120.0))
session.commit()
answer = client.get(
f"{settings.API_V1_STR}/runs/series/compare",
params={"ids": plotted, "metric": "study.loss", "x": "study.epoch"},
headers=superuser_token_headers,
).json()
assert [point[0] for point in answer["lines"][0]["points"]] == [10.0, 20.0, 30.0]
+1 -1
View File
@@ -109,7 +109,7 @@ published to. Flows own the namespace; everything else is a client of it.
| `GET` | `/runs/{id}` | one run in full: params, result, per-node record, artifacts |
| `POST` | `/runs/{id}/cancel` | stop it |
| `GET` | `/runs/{id}/metrics?name=&stride=` | one metric's series, in step order; every series of the run without `name` |
| `GET` | `/runs/series/compare?ids=a,b,c&metric=` | that metric across several runs |
| `GET` | `/runs/series/compare?ids=a,b,c&metric=&x=` | that metric across several runs. `x` is what to plot against: nothing or `step`, `time` (seconds since each run's own first reading), or another metric's name, joined on the step the two share |
Submitting answers immediately with a `queued` run. Wrong parameters — an
undeclared name, a value of the wrong type — come back as a 422 naming the
+8 -1
View File
@@ -295,9 +295,16 @@ flow, by status, or down to one sweep. A sweep is worth filtering to — the
table then draws a column per parameter that actually varied, which is what
makes fifty runs of one flow readable.
Tick two or more and their curves go side by side. That comparison is the
Tick two or more and their curves go side by side — shift-click to take a
range, or the header box to take everything on screen. That comparison is the
address, so a link to it is a link someone else can open.
The curves are drawn against the step by default. They can also be drawn
against elapsed seconds, which answers "which one got there sooner" and is
measured from each run's own first reading so that runs started hours apart
still lie on top of each other; or against another metric of the same runs —
an epoch, or samples seen — joined on the step the two share.
One run in full is params, the per-node record with its logs and traceback,
the artifacts it made, its metrics and its result.
+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" }] }),
})