From 4350916bd86529f0e64c518034ccab44ed0f8155 Mon Sep 17 00:00:00 2001 From: stroblme Date: Tue, 25 Aug 2026 16:32:34 +0200 Subject: [PATCH] Scroll the runs table, pick a range, and choose what a comparison plots against --- backend/fluksio/api/routes/runs.py | 37 +- backend/tests/api/routes/test_runs.py | 84 ++++- docs/code/api.md | 2 +- docs/concepts/runs.md | 9 +- frontend/src/client/schemas.gen.ts | 5 + frontend/src/client/sdk.gen.ts | 7 +- frontend/src/client/types.gen.ts | 2 + .../src/components/Dashboard/ChartWidget.tsx | 3 +- frontend/src/components/Runs/MetricChart.tsx | 63 +++- frontend/src/components/Runs/RunDetail.tsx | 54 ++- frontend/src/components/Runs/RunsScreen.tsx | 344 ++++++++++++------ frontend/src/components/Runs/queries.ts | 22 +- frontend/src/components/ui/table.tsx | 16 +- frontend/src/routes/_layout/runs/index.tsx | 1 + 14 files changed, 498 insertions(+), 151 deletions(-) diff --git a/backend/fluksio/api/routes/runs.py b/backend/fluksio/api/routes/runs.py index ca742b0..d3f90fa 100644 --- a/backend/fluksio/api/routes/runs.py +++ b/backend/fluksio/api/routes/runs.py @@ -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) diff --git a/backend/tests/api/routes/test_runs.py b/backend/tests/api/routes/test_runs.py index 5059b5c..633d432 100644 --- a/backend/tests/api/routes/test_runs.py +++ b/backend/tests/api/routes/test_runs.py @@ -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] diff --git a/docs/code/api.md b/docs/code/api.md index 0c424af..52a2963 100644 --- a/docs/code/api.md +++ b/docs/code/api.md @@ -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 diff --git a/docs/concepts/runs.md b/docs/concepts/runs.md index 32c3f59..a2fd80e 100644 --- a/docs/concepts/runs.md +++ b/docs/concepts/runs.md @@ -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. diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 1f452f7..fa2fcaa 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -2622,6 +2622,11 @@ export const SeriesAnswerSchema = { type: 'string', title: 'Metric' }, + x: { + type: 'string', + title: 'X', + default: 'step' + }, lines: { items: { '$ref': '#/components/schemas/MetricSeries' diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index b667951..1f28b75 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -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' diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 72b7cf6..92221ac 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -929,6 +929,7 @@ export type SecretValue = { export type SeriesAnswer = { metric: string; + x?: string; lines?: Array; }; @@ -1611,6 +1612,7 @@ export type RunsReadMetricsResponse = (Array); export type RunsCompareMetricData = { ids: string; metric: string; + x?: string; }; export type RunsCompareMetricResponse = (SeriesAnswer); diff --git a/frontend/src/components/Dashboard/ChartWidget.tsx b/frontend/src/components/Dashboard/ChartWidget.tsx index cb1d4da..c593b05 100644 --- a/frontend/src/components/Dashboard/ChartWidget.tsx +++ b/frontend/src/components/Dashboard/ChartWidget.tsx @@ -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) diff --git a/frontend/src/components/Runs/MetricChart.tsx b/frontend/src/components/Runs/MetricChart.tsx index 568ba02..8cad4e7 100644 --- a/frontend/src/components/Runs/MetricChart.tsx +++ b/frontend/src/components/Runs/MetricChart.tsx @@ -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 ( -
+
- + @@ -97,3 +109,44 @@ export function MetricPicker({ ) } + +/** + * 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 ( + + ) +} diff --git a/frontend/src/components/Runs/RunDetail.tsx b/frontend/src/components/Runs/RunDetail.tsx index 65beee8..d4cd5c8 100644 --- a/frontend/src/components/Runs/RunDetail.tsx +++ b/frontend/src/components/Runs/RunDetail.tsx @@ -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 }) { ) : (
{Object.entries(run.params).map(([key, value]) => ( -
-
{key}
-
- {paramText(value)} -
-
+ ))}
)} @@ -154,15 +147,7 @@ export function RunDetail({ id }: { id: string }) {

Result

{Object.entries(run.result ?? {}).map(([key, value]) => ( -
-
{key}
-
- {paramText(value)} -
-
+ ))}
@@ -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 ( +
+
{name}
+
+ {structured ? ( + + ) : ( + paramText(value) + )} +
+
+ ) +} + function Fact({ label, children, diff --git a/frontend/src/components/Runs/RunsScreen.tsx b/frontend/src/components/Runs/RunsScreen.tsx index e871525..1fd8058 100644 --- a/frontend/src/components/Runs/RunsScreen.tsx +++ b/frontend/src/components/Runs/RunsScreen.tsx @@ -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(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({ )}

{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 + } + return {paramText(value)} +} + 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 ( -

- - - - - Run - {showFlow && Flow} - Status - {varying.length > 0 ? ( - varying.map((key) => {key}) - ) : ( - Parameters - )} - Seed - Code - Took - Started - By - - - - {runs.length === 0 && ( - - - No runs match this filter. - - +
+ + + + 0 ? "indeterminate" : false} + onCheckedChange={onSelectAll} + aria-label={ + all ? "Clear the selection" : "Select every run shown" + } + data-testid="run-select-all" + /> + + Run + {showFlow && Flow} + Status + {varying.length > 0 ? ( + varying.map((key) => {key}) + ) : ( + Parameters )} - {runs.map((run) => ( - - - onToggle(run.id)} - aria-label={`Compare ${run.id}`} - data-testid="run-select" - /> - - -
- Seed + Code + Duration + + + + {runs.length === 0 && ( + + + No runs match this filter. + + + )} + {runs.map((run, index) => ( + + + onSelect(index, shift.current)} + aria-label={`Compare ${run.id}`} + data-testid="run-select" + /> + + +
+ + {shortId(run.id)} + + {run.group_id && !search.group && ( + - )} -
-
- {showFlow && ( - - {run.flow} - - )} - - - - {varying.length > 0 ? ( - varying.map((key) => ( - - {paramText(run.params[key])} - - )) - ) : ( - - {paramsSummary(run.params) || "—"} - - )} - - {run.seed ?? "—"} - - - {/* 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 || "") || "—"} - - - {run.duration_ms ? dur(run.duration_ms) : "—"} - + sweep + + )} +
+
+ {showFlow && ( - {ago(String(run.created_at ?? ""))} + {run.flow} - - {run.actor || "—"} + )} + + + + {varying.length > 0 ? ( + varying.map((key) => ( + + + + )) + ) : ( + + {Object.keys(run.params).length === 0 ? ( + + ) : ( +
+ {Object.entries(run.params).map(([key, value]) => ( + + + {key} + + + + ))} +
+ )}
-
- ))} - -
-
+ )} + + {run.seed ?? "—"} + + + {/* 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 || "") || "—"} + + + {/* 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 ?? ""))} + | + + {run.duration_ms ? dur(run.duration_ms) : "—"} + + + + ))} + + ) } @@ -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 && ( + + update({ x: next === STEP_AXIS ? undefined : next }) + } + /> + )}