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 ( +
+
+ + +
+ + + (message.dtype === "float" || message.dtype === "int") && + (!runs.flow || message.name.startsWith(`${runs.flow}.`)) + } + onPick={(metric) => onChange({ ...runs, metric })} + /> + + + onChange({ + ...runs, + group: undefined, + ids: undefined, + latest: picked === "latest" ? (runs.latest ?? 3) : undefined, + }) + } + /> + + {mode === "latest" && ( +
+ + + onChange({ ...runs, latest: Number(event.target.value) }) + } + /> +
+ )} + + {mode === "group" && ( +
+ + + onChange({ ...runs, group: event.target.value }) + } + /> +
+ )} + + {mode === "ids" && ( +
+ + + onChange({ + ...runs, + ids: event.target.value + .split(",") + .map((one) => one.trim()) + .filter(Boolean), + }) + } + /> +
+ )} + +
+ + onRefresh(Number(event.target.value))} + /> +
+
+ ) +} + /** 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" ? (
set({ source })} /> - {querying ? ( + {pinnedRuns ? ( + set({ runs })} + onRefresh={(refresh_s) => set({ refresh_s })} + /> + ) : querying ? (
- // 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)