Pin a chart to the last few runs of a flow

This commit is contained in:
2026-08-25 11:52:34 +02:00
parent 5ece544b14
commit 77754936c6
7 changed files with 331 additions and 12 deletions
+36 -1
View File
@@ -33,6 +33,10 @@ DASHBOARD_DIR = "_dashboards"
#: A chart cannot ask for an unbounded series; this is the ceiling. #: A chart cannot ask for an unbounded series; this is the ceiling.
HISTORY_CAP = 5000 HISTORY_CAP = 5000
#: How many runs one pinned chart puts side by side. The client's own
#: ``MAX_SERIES``: the palette is five steps, and a sixth line repeats one.
RUN_LINES = 5
#: How many readings one bar draws. Mirrored in the client #: How many readings one bar draws. Mirrored in the client
#: (``ui/core/config.ts``, ``MAX_ROWS``). #: (``ui/core/config.ts``, ``MAX_ROWS``).
#: #:
@@ -207,6 +211,17 @@ class WidgetDef(BaseModel):
"""A chart that asks a flow for its series instead of reading the ring.""" """A chart that asks a flow for its series instead of reading the ring."""
return self.type == "chart" and self.config.get("source") == "query" return self.type == "chart" and self.config.get("source") == "query"
@property
def _runs_chart(self) -> bool:
"""A chart pinned to runs: it reads the run tables, not the engine.
The other way round from opening a dashboard against a run — that is a
way of looking at a page, this is a tile that always shows the last
few runs of something, which is what a wall panel over a lab bench
wants.
"""
return self.type == "chart" and self.config.get("source") == "runs"
@property @property
def inner_bindings(self) -> Bindings: def inner_bindings(self) -> Bindings:
"""A bar's nested readings, as documents written before rows carry them. """A bar's nested readings, as documents written before rows carry them.
@@ -245,6 +260,10 @@ class WidgetDef(BaseModel):
@property @property
def messages(self) -> list[str]: def messages(self) -> list[str]:
"""Every message name this widget reads.""" """Every message name this widget reads."""
if self._runs_chart:
# Its metric is a run's recorded series, which no live message
# carries — nothing for the engine to route or to keep.
return []
if self._query_chart: if self._query_chart:
name = self.config.get("message") name = self.config.get("message")
return [str(name)] if name else [] return [str(name)] if name else []
@@ -280,7 +299,7 @@ class WidgetDef(BaseModel):
Nothing, for a chart that queries: the answer carries its own past, so Nothing, for a chart that queries: the answer carries its own past, so
asking the engine to keep a ring as well would store it twice. asking the engine to keep a ring as well would store it twice.
""" """
if self.type != "chart" or self._query_chart: if self.type != "chart" or self._query_chart or self._runs_chart:
return 0 return 0
points = int((self.config.get("history") or {}).get("points") or 0) points = int((self.config.get("history") or {}).get("points") or 0)
return min(points, HISTORY_CAP) return min(points, HISTORY_CAP)
@@ -304,6 +323,21 @@ class WidgetDef(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def _check_binding(self) -> WidgetDef: def _check_binding(self) -> WidgetDef:
"""Refuse a widget wired to a message it cannot carry.""" """Refuse a widget wired to a message it cannot carry."""
if self._runs_chart:
runs = self.config.get("runs") or {}
if not runs.get("metric"):
raise ValueError("a chart of runs must name the metric it draws")
if not (runs.get("ids") or runs.get("group") or runs.get("flow")):
raise ValueError(
"a chart of runs must say which: a flow, a sweep, or run ids"
)
latest = int(runs.get("latest") or 1)
if not 1 <= latest <= RUN_LINES:
raise ValueError(f"a chart draws between 1 and {RUN_LINES} runs")
if len(runs.get("ids") or []) > RUN_LINES:
raise ValueError(f"a chart draws at most {RUN_LINES} runs")
return self
if self._query_chart: if self._query_chart:
for key, want in (("dtype", "series"), ("request_dtype", "record")): for key, want in (("dtype", "series"), ("request_dtype", "record")):
bound = str(self.config.get(key) or "") bound = str(self.config.get(key) or "")
@@ -818,6 +852,7 @@ def results_dashboard(flow: FlowDef) -> DashboardDef:
__all__ = [ __all__ = [
"BAR_ROWS", "BAR_ROWS",
"RUN_LINES",
"COLOR_DTYPES", "COLOR_DTYPES",
"DASHBOARD_DIR", "DASHBOARD_DIR",
"HISTORY_CAP", "HISTORY_CAP",
+21
View File
@@ -493,3 +493,24 @@ def test_a_flow_declaring_no_outputs_draws_no_stats():
defn = results_dashboard(training_flow().model_copy(update={"outputs": []})) defn = results_dashboard(training_flow().model_copy(update={"outputs": []}))
assert [w.type for w in defn.widgets] == ["chart"] assert [w.type for w in defn.widgets] == ["chart"]
def runs_chart(**runs) -> WidgetDef:
return WidgetDef(id="curves", type="chart", config={"source": "runs", "runs": runs})
def test_a_chart_pinned_to_runs_reads_no_live_message():
"""Its series is in the run tables; the engine has nothing to keep for it."""
widget = runs_chart(metric="study.loss", flow="study", latest=3)
assert widget.messages == []
assert widget.history_points == 0
def test_a_chart_of_runs_must_say_which_runs_and_which_metric():
with pytest.raises(ValueError, match="metric"):
runs_chart(flow="study")
with pytest.raises(ValueError, match="a flow, a sweep, or run ids"):
runs_chart(metric="study.loss")
with pytest.raises(ValueError, match="between 1 and 5"):
runs_chart(metric="study.loss", flow="study", latest=9)
@@ -1,4 +1,4 @@
import { useQueries } from "@tanstack/react-query" import { useQueries, useQuery } from "@tanstack/react-query"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import type { HistoryPoint } from "@/client" import type { HistoryPoint } from "@/client"
@@ -9,7 +9,12 @@ import {
} from "@/components/Common/RangePicker" } from "@/components/Common/RangePicker"
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart" import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
import { useLiveValue, useLiveValues } from "@/components/Flow/liveStore" import { useLiveValue, useLiveValues } from "@/components/Flow/liveStore"
import { NO_CURVE } from "@/components/Runs/queries" import {
compareQueryOptions,
NO_CURVE,
runsListQueryOptions,
shortenRunLabel,
} from "@/components/Runs/queries"
import { type DataContext, useDataContext } from "./dataContext" import { type DataContext, useDataContext } from "./dataContext"
import { messageHistoryQueryOptions, usePublishMessage } from "./queries" import { messageHistoryQueryOptions, usePublishMessage } from "./queries"
import { usePalette } from "./settings" import { usePalette } from "./settings"
@@ -96,13 +101,86 @@ export function ChartWidget(props: WidgetProps) {
// draws from that instead, whichever source it was pointed at otherwise. // draws from that instead, whichever source it was pointed at otherwise.
const context = useDataContext() const context = useDataContext()
if (context) return <ContextChart {...props} context={context} /> if (context) return <ContextChart {...props} context={context} />
return config(props.widget).source === "query" ? ( const source = config(props.widget).source
if (source === "runs") return <RunsChart {...props} />
return source === "query" ? (
<QueryChart {...props} /> <QueryChart {...props} />
) : ( ) : (
<LiveChart {...props} /> <LiveChart {...props} />
) )
} }
/**
* The last few runs of something, side by side, always.
*
* A tile rather than a way of looking at the page: what a panel over a lab
* bench shows without anyone picking runs first. The comparison endpoint
* already answers in the shape this draws, so the widget only has to decide
* which runs it means.
*/
function RunsChart({ widget }: WidgetProps) {
const cfg = (widget.config ?? {}) as {
runs?: {
metric?: string
flow?: string
group?: string
ids?: string[]
latest?: number
}
refresh_s?: number
}
const pick = cfg.runs ?? {}
const metric = pick.metric ?? ""
const explicit = pick.ids ?? []
const refreshMs = Math.max(15, Number(cfg.refresh_s) || 60) * 1000
// Named runs need no lookup. Otherwise the newest of a sweep, or of a flow —
// and of a flow only the ones that finished, since a panel comparing curves
// wants results rather than the crash from ten minutes ago.
const found = useQuery({
...runsListQueryOptions(
pick.group
? { group: pick.group }
: {
flow: pick.flow,
status: "ok",
limit: Math.min(Math.max(Number(pick.latest) || 3, 1), MAX_SERIES),
},
),
enabled: explicit.length === 0 && Boolean(pick.group || pick.flow),
})
const ids = explicit.length
? explicit.slice(0, MAX_SERIES)
: (found.data ?? []).slice(0, MAX_SERIES).map((run) => run.id)
const { data, isPending } = useQuery(
compareQueryOptions(ids, metric, refreshMs),
)
const palette = usePalette()
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
const points = lines.reduce(
(total, line) => total + (line.points?.length ?? 0),
0,
)
if (!metric) return <p className="text-muted-foreground">Pick a metric.</p>
return (
<UplotChart
labels={lines.map((line) => shortenRunLabel(line.label))}
plots={lines.map((line) =>
(line.points ?? []).map(([step, value]) => ({ ts: step, value })),
)}
palette={palette}
xTime={false}
pending={isPending || found.isPending}
empty={points === 0 ? NO_CURVE : undefined}
{...presentation(widget.config as Record<string, unknown>)}
/>
)
}
/** /**
* The same chart, drawn from a data context. * The same chart, drawn from a data context.
* *
+160 -2
View File
@@ -9,6 +9,7 @@ import {
PanelTitle, PanelTitle,
SidePanel, SidePanel,
} from "@/components/Flow/SidePanel" } from "@/components/Flow/SidePanel"
import { runOverviewQueryOptions } from "@/components/Runs/queries"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -144,10 +145,158 @@ function MessagePicker({
) )
} }
/** Which runs a pinned chart draws, and which of their metrics. */
type RunsPick = {
metric?: string
flow?: string
group?: string
ids?: string[]
latest?: number
}
/** How a pinned chart says which runs it means. */
const RUN_PICKS = [
["latest", "Latest"],
["group", "Sweep"],
["ids", "Named"],
] as const
/**
* The runs a chart is pinned to.
*
* The flows offered are the ones that have actually run — a flow with no runs
* has no curve to draw, and the run tables already know which those are.
*/
function RunsSourceFields({
runs,
refreshS,
onChange,
onRefresh,
}: {
runs: RunsPick
refreshS: unknown
onChange: (next: RunsPick) => void
onRefresh: (seconds: number) => void
}) {
const { data: flows } = useQuery(runOverviewQueryOptions())
const mode = runs.ids?.length ? "ids" : runs.group ? "group" : "latest"
return (
<div className="grid gap-2">
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Of flow</Label>
<Select
value={runs.flow ?? ""}
onValueChange={(flow) => onChange({ ...runs, flow })}
>
<SelectTrigger data-testid="runs-flow">
<SelectValue placeholder="Pick a flow" />
</SelectTrigger>
<SelectContent>
{(flows ?? []).map((row) => (
<SelectItem key={row.flow} value={row.flow}>
{row.flow}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<MessagePicker
kind="chart"
value={runs.metric ?? ""}
label="Draws"
testId="runs-metric"
placeholder="Pick a metric"
filter={(message) =>
(message.dtype === "float" || message.dtype === "int") &&
(!runs.flow || message.name.startsWith(`${runs.flow}.`))
}
onPick={(metric) => onChange({ ...runs, metric })}
/>
<Segmented
value={mode}
options={RUN_PICKS}
label="Which runs"
testId="runs-pick"
onChange={(picked) =>
onChange({
...runs,
group: undefined,
ids: undefined,
latest: picked === "latest" ? (runs.latest ?? 3) : undefined,
})
}
/>
{mode === "latest" && (
<div className="grid gap-1.5">
<Label className="font-normal text-sm">How many</Label>
<Input
type="number"
min={1}
max={MAX_SERIES}
value={runs.latest ?? 3}
aria-label="How many runs"
onChange={(event) =>
onChange({ ...runs, latest: Number(event.target.value) })
}
/>
</div>
)}
{mode === "group" && (
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Sweep</Label>
<Input
value={runs.group ?? ""}
placeholder="A sweep's group id"
onChange={(event) =>
onChange({ ...runs, group: event.target.value })
}
/>
</div>
)}
{mode === "ids" && (
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Runs</Label>
<Input
value={(runs.ids ?? []).join(",")}
placeholder="Run ids, comma separated"
onChange={(event) =>
onChange({
...runs,
ids: event.target.value
.split(",")
.map((one) => one.trim())
.filter(Boolean),
})
}
/>
</div>
)}
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Refresh (seconds)</Label>
<Input
type="number"
min={15}
value={Number(refreshS) || 60}
aria-label="Refresh seconds"
onChange={(event) => onRefresh(Number(event.target.value))}
/>
</div>
</div>
)
}
/** Where a chart's lines come from: what the engine kept, or what it asks for. */ /** Where a chart's lines come from: what the engine kept, or what it asks for. */
const CHART_SOURCES = [ const CHART_SOURCES = [
["live", "Live"], ["live", "Live"],
["query", "Query"], ["query", "Query"],
["runs", "Runs"],
] as const ] as const
/** /**
@@ -278,6 +427,7 @@ export function WidgetPanel({
onChange({ config: { ...cfg, ...changes } }) onChange({ config: { ...cfg, ...changes } })
const series = seriesOf(widget) const series = seriesOf(widget)
const runsPick = (cfg.runs ?? {}) as RunsPick
const setSeries = (next: Series[]) => set({ series: next }) const setSeries = (next: Series[]) => set({ series: next })
// A bar's readings. An empty row stands in for none, so a fresh bar offers // A bar's readings. An empty row stands in for none, so a fresh bar offers
// the picker rather than only a button. // the picker rather than only a button.
@@ -305,6 +455,7 @@ export function WidgetPanel({
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[] const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
const setOptions = (next: typeof options) => set({ options: next }) const setOptions = (next: typeof options) => set({ options: next })
const querying = cfg.source === "query" const querying = cfg.source === "query"
const pinnedRuns = cfg.source === "runs"
// What this chart would refresh at with nothing configured. The viewer can // What this chart would refresh at with nothing configured. The viewer can
// pick another window on the widget, which moves it. // pick another window on the widget, which moves it.
const paced = refreshFor( const paced = refreshFor(
@@ -375,13 +526,20 @@ export function WidgetPanel({
) : widget.type === "chart" ? ( ) : widget.type === "chart" ? (
<div className="grid gap-3"> <div className="grid gap-3">
<Segmented <Segmented
value={querying ? "query" : "live"} value={pinnedRuns ? "runs" : querying ? "query" : "live"}
options={CHART_SOURCES} options={CHART_SOURCES}
label="Where the chart's data comes from" label="Where the chart's data comes from"
testId="chart-source" testId="chart-source"
onChange={(source) => set({ source })} onChange={(source) => set({ source })}
/> />
{querying ? ( {pinnedRuns ? (
<RunsSourceFields
runs={runsPick}
refreshS={cfg.refresh_s}
onChange={(runs) => set({ runs })}
onRefresh={(refresh_s) => set({ refresh_s })}
/>
) : querying ? (
<div className="grid gap-2"> <div className="grid gap-2">
<MessagePicker <MessagePicker
kind="chart" kind="chart"
@@ -118,6 +118,20 @@ export function widgetIssue(widget: WidgetDef): string | null {
if (widget.type === "markdown" || widget.type === "clock") return null if (widget.type === "markdown" || widget.type === "clock") return null
const cfg = config(widget) const cfg = config(widget)
if (widget.type === "chart" && cfg.source === "runs") {
const runs = (cfg.runs ?? {}) as {
metric?: string
flow?: string
group?: string
ids?: string[]
}
if (!runs.metric) return "This chart names no run metric yet."
if (!runs.ids?.length && !runs.group && !runs.flow) {
return "Say which runs: a flow, a sweep, or run ids."
}
return null
}
if (widget.type === "chart" && cfg.source === "query") { if (widget.type === "chart" && cfg.source === "query") {
if (!text(cfg.request)) return "This chart does not ask for anything yet." if (!text(cfg.request)) return "This chart does not ask for anything yet."
if (!text(cfg.message)) return "This chart has no answer to draw yet." if (!text(cfg.message)) return "This chart has no answer to draw yet."
+2 -6
View File
@@ -13,7 +13,7 @@ import {
compareQueryOptions, compareQueryOptions,
NO_CURVE, NO_CURVE,
runMetricsQueryOptions, runMetricsQueryOptions,
shortId, shortenRunLabel,
} from "./queries" } from "./queries"
/** The metrics one run recorded, in the order they are worth offering. */ /** The metrics one run recorded, in the order they are worth offering. */
@@ -46,11 +46,7 @@ export function RunMetricChart({
compareQueryOptions(ids, metric, refreshMs), compareQueryOptions(ids, metric, refreshMs),
) )
const lines = (data?.lines ?? []).slice(0, MAX_SERIES) const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
const labels = lines.map((line) => const labels = lines.map((line) => shortenRunLabel(line.label))
// The endpoint labels a line with the whole run id, which is too long to
// read in a legend beside four others.
line.label.replace(/^\S+/, (id) => shortId(id)),
)
const plots: HistoryPoint[][] = lines.map((line) => const plots: HistoryPoint[][] = lines.map((line) =>
(line.points ?? []).map(([step, value]) => ({ ts: step, value })), (line.points ?? []).map(([step, value]) => ({ ts: step, value })),
) )
+17
View File
@@ -61,6 +61,14 @@ export const runsInfiniteQueryOptions = (filters: RunFilters) => ({
}, },
}) })
/** One page of runs, for a caller that pages nothing — a pinned chart. */
export const runsListQueryOptions = (
filters: RunFilters & { limit?: number },
) => ({
queryKey: [...runKeys.all, "pinned", filters] as const,
queryFn: () => RunsService.readRuns(filters),
})
export const runOverviewQueryOptions = () => ({ export const runOverviewQueryOptions = () => ({
queryKey: runKeys.overview, queryKey: runKeys.overview,
queryFn: () => RunsService.readOverview(), queryFn: () => RunsService.readOverview(),
@@ -138,6 +146,15 @@ export const NO_CURVE =
/** A run id, short enough for a table cell. The tail is the random half. */ /** A run id, short enough for a table cell. The tail is the random half. */
export const shortId = (id: string) => id.slice(-8) export const shortId = (id: string) => id.slice(-8)
/**
* A series label from the comparison endpoint, cut down to fit a legend.
*
* It leads with the whole run id, which is 22 characters of mostly timestamp
* beside four others.
*/
export const shortenRunLabel = (label: string) =>
label.replace(/^\S+/, (id) => shortId(id))
/** A commit, at the length everyone reads one at. */ /** A commit, at the length everyone reads one at. */
export const shortCommit = (commit: string) => commit.slice(0, 7) export const shortCommit = (commit: string) => commit.slice(0, 7)