diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py
index aa76be3..edf1d6b 100644
--- a/backend/fluksio/flow/dashboards.py
+++ b/backend/fluksio/flow/dashboards.py
@@ -33,6 +33,10 @@ DASHBOARD_DIR = "_dashboards"
#: A chart cannot ask for an unbounded series; this is the ceiling.
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
#: (``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."""
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
def inner_bindings(self) -> Bindings:
"""A bar's nested readings, as documents written before rows carry them.
@@ -245,6 +260,10 @@ class WidgetDef(BaseModel):
@property
def messages(self) -> list[str]:
"""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:
name = self.config.get("message")
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
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
points = int((self.config.get("history") or {}).get("points") or 0)
return min(points, HISTORY_CAP)
@@ -304,6 +323,21 @@ class WidgetDef(BaseModel):
@model_validator(mode="after")
def _check_binding(self) -> WidgetDef:
"""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:
for key, want in (("dtype", "series"), ("request_dtype", "record")):
bound = str(self.config.get(key) or "")
@@ -818,6 +852,7 @@ def results_dashboard(flow: FlowDef) -> DashboardDef:
__all__ = [
"BAR_ROWS",
+ "RUN_LINES",
"COLOR_DTYPES",
"DASHBOARD_DIR",
"HISTORY_CAP",
diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py
index d13b717..c1c5ba4 100644
--- a/backend/tests/flow/test_dashboards.py
+++ b/backend/tests/flow/test_dashboards.py
@@ -493,3 +493,24 @@ def test_a_flow_declaring_no_outputs_draws_no_stats():
defn = results_dashboard(training_flow().model_copy(update={"outputs": []}))
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)
diff --git a/frontend/src/components/Dashboard/ChartWidget.tsx b/frontend/src/components/Dashboard/ChartWidget.tsx
index 9cf7fc6..cb1d4da 100644
--- a/frontend/src/components/Dashboard/ChartWidget.tsx
+++ b/frontend/src/components/Dashboard/ChartWidget.tsx
@@ -1,4 +1,4 @@
-import { useQueries } from "@tanstack/react-query"
+import { useQueries, useQuery } from "@tanstack/react-query"
import { useEffect, useRef, useState } from "react"
import type { HistoryPoint } from "@/client"
@@ -9,7 +9,12 @@ import {
} from "@/components/Common/RangePicker"
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
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 { messageHistoryQueryOptions, usePublishMessage } from "./queries"
import { usePalette } from "./settings"
@@ -96,13 +101,86 @@ export function ChartWidget(props: WidgetProps) {
// draws from that instead, whichever source it was pointed at otherwise.
const context = useDataContext()
if (context) return
- return config(props.widget).source === "query" ? (
+ const source = config(props.widget).source
+ if (source === "runs") return
+ return source === "query" ? (
) : (
)
}
+/**
+ * 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
Pick a metric.
+
+ return (
+ 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)}
+ />
+ )
+}
+
/**
* The same chart, drawn from a data context.
*
diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx
index d122aa8..2f948f3 100644
--- a/frontend/src/components/Dashboard/panels.tsx
+++ b/frontend/src/components/Dashboard/panels.tsx
@@ -9,6 +9,7 @@ import {
PanelTitle,
SidePanel,
} from "@/components/Flow/SidePanel"
+import { runOverviewQueryOptions } from "@/components/Runs/queries"
import { Button } from "@/components/ui/button"
import {
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 (
+
+ )
+}
+
/** Where a chart's lines come from: what the engine kept, or what it asks for. */
const CHART_SOURCES = [
["live", "Live"],
["query", "Query"],
+ ["runs", "Runs"],
] as const
/**
@@ -278,6 +427,7 @@ export function WidgetPanel({
onChange({ config: { ...cfg, ...changes } })
const series = seriesOf(widget)
+ const runsPick = (cfg.runs ?? {}) as RunsPick
const setSeries = (next: Series[]) => set({ series: next })
// A bar's readings. An empty row stands in for none, so a fresh bar offers
// the picker rather than only a button.
@@ -305,6 +455,7 @@ export function WidgetPanel({
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
const setOptions = (next: typeof options) => set({ options: next })
const querying = cfg.source === "query"
+ const pinnedRuns = cfg.source === "runs"
// What this chart would refresh at with nothing configured. The viewer can
// pick another window on the widget, which moves it.
const paced = refreshFor(
@@ -375,13 +526,20 @@ export function WidgetPanel({
) : widget.type === "chart" ? (
- // 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 labels = lines.map((line) => shortenRunLabel(line.label))
const plots: HistoryPoint[][] = lines.map((line) =>
(line.points ?? []).map(([step, value]) => ({ ts: step, value })),
)
diff --git a/frontend/src/components/Runs/queries.ts b/frontend/src/components/Runs/queries.ts
index 3d7c558..4f332e2 100644
--- a/frontend/src/components/Runs/queries.ts
+++ b/frontend/src/components/Runs/queries.ts
@@ -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 = () => ({
queryKey: runKeys.overview,
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. */
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. */
export const shortCommit = (commit: string) => commit.slice(0, 7)