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.
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",