Files
app/backend/fluksio/flow/dashboards.py
T
stroblmeandClaude Opus 5 ff612623a1 Give both summaries a modified time, and the dashboard list a footprint
Home's "recently modified" order was a proxy — drafts first, then the
version counter, then the name. Both summaries now carry `updated_at`,
read as the mtime of the working copy: every write in the store commits
immediately, so a file's mtime is its commit time, and `git log -1 --
<path>` costs ~990ms across this instance's 15 documents (it walks the
history back to the last commit touching each one, so it is slowest for
the stalest) against ~1.8ms for the stats. No cache needed.

`DashboardSummary` also carries `footprint`: each widget as its type plus
the placement a panel resolves, which is the whole of what the mosaic
draws. That drops the document fetch per tile and with it the cap of
eight, past which tiles showed a name and nothing else. Verified against
this instance's 8 dashboards: the blocks are identical to what the old
client-side derivation produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CL9zvnnvcp1mvA8o7impxk
2026-09-06 15:26:48 +02:00

968 lines
38 KiB
Python

"""Dashboards: what a wall panel shows, and what its buttons do.
A dashboard is its own document, not a set of nodes placed in a flow. Widgets
bind to message names — the same names that wire the graph — so a dashboard
reads across flows without being part of any of them, and a flow stays the
logic it was.
Stored beside the flows in the same git repository, under a directory the flow
listing ignores. Editing is separated from showing, exactly as it is for flows:
the editor writes ``dashboard.draft.json`` and a wall panel reads only the
published ``dashboard.json``, so a half-arranged page never reaches the wall.
Publishing promotes the draft and removes it; a dashboard directory without one
is simply a dashboard with nothing unpublished. A new dashboard starts as a
draft alone, so a directory may just as well hold only the draft — a dashboard
nobody has published yet, which no panel can be shown.
"""
from __future__ import annotations
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from fluksio.flow.messages import DType, qualify
from fluksio.flow.schemas import FlowDef, _validate_name
from fluksio.flow.store import FlowStore, StaleVersion, _updated_at
#: Sibling of the shared-node library, and likewise not a flow.
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``).
#:
#: It used to be three, and the limit was contrast: the readings were nested
#: inside one fill and shared a single token, because no slot of the chart ramp
#: cleared 3:1 against the outer one. A bar now draws a row per reading in the
#: dashboard's own data colours, so what bounds it is how many tracks stay
#: legible stacked in one tile.
BAR_ROWS = 8
#: Resolved out here on purpose: the store has a ``list`` method, which
#: shadows the builtin for any annotation written inside the class.
Bindings = list[dict[str, Any]]
WidgetType = Literal[
# Display
"stat",
"gauge",
"chart",
"markdown",
"agenda",
"notification",
"bar",
"icon",
"forecast",
"clock",
"media",
"player",
"embed",
# Input
"button",
"switch",
"slider",
"input",
"dropdown",
"color",
]
#: Widgets whose only binding is the message they publish. A player is not one:
#: it publishes transport commands *and* reads what is playing, so its reading
#: is its binding and its ``target`` is checked separately.
INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown", "color"}
#: What a colour widget puts on the wire, by the format it was configured for.
#: The default is what the Node-RED instance this ports already sends its
#: DMX encoders — ``[h, s, v]``, hue in degrees and the other two in percent —
#: and the two alternatives exist because fixtures differ. Mirrored in the
#: client (``frontend/src/components/Dashboard/ColorWidget.tsx``).
COLOR_DTYPES = {"hsv": "list", "rgb": "list", "hex": "str"}
#: What a widget may be pointed at, by payload type. A switch that reads a
#: float has nothing to show and nothing safe to send, so the pairing belongs
#: to the document rather than to the editor that happened to write it. Types
#: missing here take anything. Mirrored in the client
#: (``frontend/src/components/Dashboard/widgets.tsx``).
WIDGET_DTYPES: dict[str, set[str]] = {
"gauge": {"float", "int"},
# A chart reading the engine's ring. One that queries binds a `series`
# answer and a `record` request instead, checked separately below.
"chart": {"float", "int"},
"slider": {"float", "int"},
"switch": {"bool"},
"agenda": {"list"},
"notification": {"record"},
"bar": {"float", "int"},
"forecast": {"list"},
# Either shape a colour can travel as; which of the two this widget means
# is its ``format``, checked against ``COLOR_DTYPES`` below.
"color": {"list", "str"},
# A camera frame, a clip, a segment. What it draws follows the type it is
# bound to; a plain artifact is taken as well, since the bytes may be
# anything and the media type on the reference is what says what they are.
"media": {"image", "audio", "video", "artifact"},
# What a streamer says it is playing: title, artist, status, position and
# duration in one reading, because they are one thing and a player drawn
# from five separate messages would redraw itself five times.
"player": {"record"},
# An icon maps weather strings, bool hints and numbers alike; a clock reads
# the wall and an embed a page of its own, so none has a row to be held to.
}
#: What a dashboard-wide setting may be bound to, by payload type. The channel
#: is general — a setting is a value plus an optional binding — but the wired
#: ones are a closed set, and a name missing here is simply a setting this
#: build does not act on. Mirrored in the client
#: (``frontend/src/components/Dashboard/settings.tsx``).
SETTING_DTYPES: dict[str, str] = {
# "system" | "light" | "dark". A panel in a room has no way to set the
# device preference the app otherwise inherits. Idle while a palette is
# set: that names the ground, so which way it reads is already decided.
"theme": "str",
# Read-only: the input widgets stop publishing.
"locked": "bool",
# "fluksio" | "material" | "glass": which component set draws this
# dashboard. Anything else is "fluksio", the app's own design. The three
# share every feature; only the drawing differs.
"look": "str",
# The dashboard's own colours, as an ordered list of hex strings. Position
# is the role: background, surface, primary, accent, text, and anything
# after that is another colour for a chart. Trailing roles may be left off
# and are derived from what is there.
"palette": "list",
# An image drawn under the widgets, by URL. A flow publishing to it is what
# a wallpaper that changes looks like here.
"background": "str",
}
class SettingDef(BaseModel):
"""One dashboard-wide setting: a value, and optionally where it comes from.
Unbound — no ``message`` — the setting is simply ``value``, which is what
makes a panel that is always dark cost no flow at all. Bound, a flow drives
it live and ``value`` is the fallback: what the dashboard uses until
something arrives, and whenever the message is silent.
A schedule is not a third case. A node publishing to the bound message on a
cron *is* the schedule, which is the whole reason this is a channel rather
than a switching rule per setting.
"""
value: Any = None
#: The message that drives it, or empty for a setting that is just a value.
message: str = ""
#: The payload type the editor recorded when it bound that message, so the
#: pairing can be judged from the document alone — the rule widget bindings
#: are held to.
dtype: str = ""
class Placement(BaseModel):
"""Where a widget sits in its section's grid, in grid units."""
x: int = 0
y: int = 0
w: int = 3
h: int = 2
class WidgetFootprint(Placement):
"""A widget reduced to the shape it draws: what it is, and where it sits.
The whole of what sketching a dashboard's outline needs — a mosaic tile
shades a block by kind and puts it in the grid, and reads nothing else. So
no id (the drawing has no use for one), no title, no config, and one
placement rather than the layout's breakpoint per screen size.
"""
type: WidgetType
class WidgetDef(BaseModel):
"""One tile: what it shows or does, and where it sits.
``config`` is per type — a chart names its series, a button names the
message it publishes — and is validated against the type below rather than
by a schema per class, because the whole set is small and closed.
A chart comes in two kinds. The default reads what the engine kept for a
message. One with ``source: "query"`` asks instead, and its config is
``{source, request, request_dtype: "record", message, dtype: "series",
refresh_s, range_s}``: it publishes ``{range_s, interval_s}`` to
``request`` exactly as a slider publishes a value, and draws the ``series``
a flow answers with on ``message``.
A colour widget picks what it publishes with ``format``, because fixtures
differ and a change node per tile is not the answer:
- ``hsv`` (the default) — ``[h, s, v]``, hue 0-360 degrees, saturation and
value 0-100 percent. What the Node-RED instance this ports feeds its
3CH/4CH DMX encoders, which divide by 360 and by 100.
- ``rgb`` — ``[r, g, b]``, each 0-255. The conventional range; the
reference's own encoders produce it after converting.
- ``hex`` — ``"#rrggbb"``, lowercase. Conventional likewise.
The first two are a ``list`` message, the third a ``str``, which is what
``COLOR_DTYPES`` records and the check below holds a binding to.
"""
id: str
type: WidgetType
title: str = ""
#: Keyed by breakpoint (``lg``/``md``/``sm``); missing ones are derived by
#: the client from the widest one it has.
layout: dict[str, Placement] = Field(default_factory=dict)
config: dict[str, Any] = Field(default_factory=dict)
@field_validator("id")
@classmethod
def _check_id(cls, value: str) -> str:
return _validate_name(value)
@property
def _query_chart(self) -> bool:
"""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.
One binding beside ``inner_dtype``, as a bar was written before it
stacked, or an ordered list of ``{message, dtype}``.
"""
inner = self.config.get("inner")
if isinstance(inner, list):
return [s for s in inner if isinstance(s, dict)]
dtype = self.config.get("inner_dtype")
# A recorded type with nothing bound is still a type to be held to.
if inner or dtype:
return [{"message": inner or "", "dtype": dtype}]
return []
@property
def bar_rows(self) -> Bindings:
"""The readings a bar draws, in every shape a document carries them.
Current documents write ``rows``. Before that a bar drew one reading
with up to three nested inside it, which is read here as that reading
followed by the nested ones — the same set of messages, drawn as
separate tracks — so an older dashboard keeps working without being
migrated first.
"""
rows = self.config.get("rows")
if isinstance(rows, list):
return [row for row in rows if isinstance(row, dict)]
name = self.config.get("message")
outer: Bindings = (
[{"message": name, "dtype": self.config.get("dtype")}] if name else []
)
return [*outer, *self.inner_bindings]
@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 []
if self.type == "chart":
return [
str(series.get("message"))
for series in self.config.get("series") or []
if series.get("message")
]
if self.type == "bar":
# A bar draws a row per reading, and each row binds its own.
return [
str(row.get("message")) for row in self.bar_rows if row.get("message")
]
name = self.config.get("message")
return [str(name)] if name else []
@property
def target(self) -> str:
"""The message this widget publishes, if it is an input.
A querying chart is one too: its request is a value it puts into the
graph, so the canvas draws it as an endpoint like any other control.
"""
if self._query_chart:
return str(self.config.get("request") or "")
return str(self.config.get("target") or "")
@property
def history_points(self) -> int:
"""How much past this widget needs kept for it.
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 or self._runs_chart:
return 0
points = int((self.config.get("history") or {}).get("points") or 0)
return min(points, HISTORY_CAP)
@property
def bound_dtypes(self) -> list[str]:
"""The payload types this widget was bound to, as the editor recorded.
Empty for a document written before the editor kept them, which is why
a missing type is never an error.
"""
if self.type == "chart":
return [
str(series.get("dtype") or "")
for series in self.config.get("series") or []
]
if self.type == "bar":
return [str(row.get("dtype") or "") for row in self.bar_rows]
return [str(self.config.get("dtype") or "")]
@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 "")
if bound and bound != want:
raise ValueError(
f"a querying chart's {key} must be '{want}', not '{bound}'"
)
return self
if self.type == "bar" and len(self.bar_rows) > BAR_ROWS:
raise ValueError(f"a bar draws at most {BAR_ROWS} readings")
if self.type == "color":
# The row above allows both shapes a colour travels as; the format
# is what decides which of them this widget actually sends. An
# unknown one is read as the default, exactly as the client does.
fmt = str(self.config.get("format") or "hsv")
want = COLOR_DTYPES.get(fmt, "list")
bound = str(self.config.get("dtype") or "")
if bound and bound != want:
raise ValueError(
f"a colour widget sending {fmt} needs a '{want}' message, "
f"not a '{bound}'"
)
allowed = WIDGET_DTYPES.get(self.type)
if not allowed:
return self
for dtype in self.bound_dtypes:
if dtype and dtype not in allowed:
raise ValueError(
f"a '{self.type}' widget cannot carry a '{dtype}' message"
)
return self
def _placement(widget: dict[str, Any]) -> dict[str, Any]:
"""Where a stored widget sits, by the widest breakpoint it names."""
layout = widget.get("layout") or {}
for key in ("lg", "md", "sm"):
box = layout.get(key)
if isinstance(box, dict):
return dict(box)
return {}
def _flatten_pages(pages: list[Any]) -> list[dict[str, Any]]:
"""The widgets of a document written as pages and sections.
Only the first page: no UI ever wrote a second one, and a panel carries
several whole dashboards instead. Its sections are stacked into one grid
the way the viewer always drew them, so a document that placed its widgets
keeps the arrangement it had rather than piling everything at row zero.
"""
if not pages or not isinstance(pages[0], dict):
return []
sections = [s for s in (pages[0].get("sections") or []) if isinstance(s, dict)]
lists = [
[w for w in (s.get("widgets") or []) if isinstance(w, dict)] for s in sections
]
flat = [w for widgets in lists for w in widgets]
placed = any(
(_placement(w).get("x") or 0) > 0 or (_placement(w).get("y") or 0) > 0
for w in flat
)
if len(sections) < 2 or not placed:
return flat
stacked: list[dict[str, Any]] = []
offset = 0
for widgets in lists:
bottom = 0
for widget in widgets:
box = _placement(widget)
y = max(0, int(box.get("y") or 0))
bottom = max(bottom, y + max(1, int(box.get("h") or 2)))
if offset:
widget = {
**widget,
"layout": {
**(widget.get("layout") or {}),
"lg": {**box, "y": y + offset},
},
}
stacked.append(widget)
offset += bottom
return stacked
class DashboardDef(BaseModel):
"""A dashboard as stored, and as the API hands it over."""
name: str
title: str = ""
#: How many columns the grid is cut into, so a dashboard can be matched to
#: the panel it will hang on.
columns: int = Field(default=12, ge=1, le=48)
#: The panel this dashboard is drawn for, in CSS pixels. Both the editor
#: and the wall panel scale that surface to fit whatever room they have, so
#: an arrangement does not depend on the window it was made in. Zero means
#: "unset" and the client falls back to its default.
canvas_width: int = Field(default=1920, ge=0, le=7680)
canvas_height: int = Field(default=1080, ge=0, le=4320)
#: A lucide icon name, drawn on the panel rail; empty falls back to two
#: letters of the title.
icon: str = ""
#: One grid. Pages and sections were in the schema and never in the UI —
#: only the first page was ever read and its sections were drawn as one —
#: so a dashboard is its widgets, and several dashboards on one device is
#: what a panel is for.
widgets: list[WidgetDef] = Field(default_factory=list)
#: Settings the whole dashboard carries, by name — see ``SettingDef``. The
#: one channel a dashboard consumes as a dashboard rather than as a set of
#: tiles, so a screen on a wall can be told things nobody standing at it
#: could set.
settings: dict[str, SettingDef] = Field(default_factory=dict)
#: Bumped on every save; a save based on an older one is refused.
version: int = 1
#: Whether there are unpublished changes. Reported by the store on read,
#: never stored — the draft file's existence is the only record of it.
has_draft: bool = False
@model_validator(mode="before")
@classmethod
def _flatten(cls, data: Any) -> Any:
"""Read a document written as pages and sections as one grid.
Stored dashboards live in each instance's git repository, so the
old shape is normalised on the way in rather than migrated: an
untouched document keeps working, and the next save writes it flat.
"""
if isinstance(data, dict) and "widgets" not in data and "pages" in data:
pages = data.get("pages") or []
data = {k: v for k, v in data.items() if k != "pages"}
data["widgets"] = _flatten_pages(pages)
return data
@field_validator("name")
@classmethod
def _check_name(cls, value: str) -> str:
return _validate_name(value)
@model_validator(mode="after")
def _check_settings(self) -> DashboardDef:
"""Refuse a setting driven by a message it cannot carry.
Judged from the document alone, exactly as a widget's binding is: the
picker records the payload type beside the name, so neither the editor
nor a wall panel has to fetch the catalogue to know the wiring is
wrong. A name this build does not know is left alone rather than
refused — an older instance reading a newer document simply does
not act on it.
"""
for name, setting in self.settings.items():
want = SETTING_DTYPES.get(name)
if want and setting.dtype and setting.dtype != want:
raise ValueError(
f"the '{name}' setting needs a '{want}' message, "
f"not a '{setting.dtype}'"
)
return self
@property
def setting_messages(self) -> list[str]:
"""Every message a bound setting reads. Empty for a static dashboard."""
return [s.message for s in self.settings.values() if s.message]
@property
def footprint(self) -> list[WidgetFootprint]:
"""Every widget's shape, at the width the dashboard is arranged for.
``lg`` is that arrangement; the narrower breakpoints are derived from
it, and a widget nobody has placed falls back to the grid defaults —
the same order a panel resolves a layout in.
"""
shapes = []
for widget in self.widgets:
at = (
widget.layout.get("lg")
or widget.layout.get("md")
or widget.layout.get("sm")
or Placement()
)
shapes.append(WidgetFootprint(type=widget.type, **at.model_dump()))
return shapes
class DashboardSummary(BaseModel):
"""A dashboard in a list, without its contents."""
name: str
title: str = ""
#: The glyph this dashboard draws on a panel's rail, so a list can show it
#: without reading every document.
icon: str = ""
widget_count: int = 0
has_draft: bool = False
#: Of the working copy, so publishing from a list needs no second read.
version: int = 1
#: The grid `footprint` is placed in — see ``DashboardDef.columns``.
columns: int = 12
#: Every widget's shape, so a list can draw the dashboard's outline
#: without a document read per tile.
footprint: list[WidgetFootprint] = Field(default_factory=list)
#: When the working copy was last written, so a list can be ordered by
#: what was worked on rather than by how often it has been saved.
updated_at: datetime | None = None
class DashboardsPublic(BaseModel):
data: list[DashboardSummary]
count: int
class DashboardNotFound(KeyError):
def __init__(self, name: str) -> None:
super().__init__(name)
self.name = name
class DashboardExists(ValueError):
def __init__(self, name: str) -> None:
super().__init__(name)
self.name = name
class DashboardStore:
"""Dashboards in the flow store's repository, invisible to the flow listing.
Shares the flow store's write lock and commit, so a dashboard save and a
flow save cannot interleave into one confused commit.
"""
def __init__(self, flows: FlowStore) -> None:
self.flows = flows
self.root = flows.root / DASHBOARD_DIR
# Read-modify-write of the version counter, same as the flow store.
self._lock = threading.Lock()
def _file(self, name: str) -> Path:
return self.root / name / "dashboard.json"
def _draft_file(self, name: str) -> Path:
return self.root / name / "dashboard.draft.json"
@staticmethod
def _dump(defn: DashboardDef) -> str:
"""What goes on disk. ``has_draft`` is the file layout, not a field."""
return defn.model_dump_json(indent=2, exclude={"has_draft"})
def list(self) -> list[DashboardSummary]:
"""Every dashboard the editor knows, published or not."""
names = {path.parent.name for path in self.root.glob("*/dashboard.json")}
names |= {path.parent.name for path in self.root.glob("*/dashboard.draft.json")}
summaries = []
for name in sorted(names):
try:
defn = self.read(name, draft=True)
except Exception:
continue
summaries.append(
DashboardSummary(
name=defn.name,
title=defn.title,
icon=defn.icon,
widget_count=len(defn.widgets),
has_draft=defn.has_draft,
version=defn.version,
columns=defn.columns,
footprint=defn.footprint,
updated_at=self.updated_at(name),
)
)
return summaries
def exists(self, name: str) -> bool:
return self._file(name).exists() or self._draft_file(name).exists()
def is_published(self, name: str) -> bool:
"""Is there a document a panel can be shown?"""
return self._file(name).exists()
def has_draft(self, name: str) -> bool:
"""Are there unpublished changes to this dashboard?"""
return self._draft_file(name).exists()
def updated_at(self, name: str) -> datetime | None:
"""When this dashboard was last written, draft or published."""
return _updated_at([self._file(name), self._draft_file(name)])
def read(self, name: str, draft: bool = False) -> DashboardDef:
"""The published dashboard, or with ``draft`` the working copy."""
path = self._draft_file(name) if draft else self._file(name)
if not path.exists():
path = self._file(name)
if not path.exists():
raise DashboardNotFound(name)
return DashboardDef.model_validate_json(path.read_text()).model_copy(
update={"has_draft": self.has_draft(name)}
)
def write(
self, defn: DashboardDef, base_version: int | None = None
) -> DashboardDef:
"""Publish a dashboard directly, skipping the draft.
The API never does: it creates a draft and promotes it. This is for a
caller that already has the finished document — a test, or a seed.
"""
with self._lock, self.flows._write_lock:
path = self._file(defn.name)
current = 0
if path.exists():
current = DashboardDef.model_validate_json(path.read_text()).version
if base_version is not None and base_version != current:
raise StaleVersion(defn.name, current)
saved = defn.model_copy(update={"version": current + 1, "has_draft": False})
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(self._dump(saved))
self.flows._commit(f"Save dashboard '{defn.name}'")
return saved
def write_draft(
self, defn: DashboardDef, base_version: int | None = None
) -> DashboardDef:
"""Save unpublished changes, refusing to overwrite someone else's.
``base_version`` is the version the editor last saw — of the working
copy, which is the draft once there is one, and 0 for a dashboard that
does not exist yet: creating one is its first draft.
"""
with self._lock, self.flows._write_lock:
current = 0
if self.exists(defn.name):
current = self.read(defn.name, draft=True).version
if base_version is not None and base_version != current:
raise StaleVersion(defn.name, current)
saved = defn.model_copy(update={"version": current + 1, "has_draft": True})
path = self._draft_file(defn.name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(self._dump(saved))
self.flows._commit(f"Update draft of dashboard '{defn.name}'")
return saved
def publish(self, name: str, base_version: int | None = None) -> DashboardDef:
"""Promote the working copy to what the panels show."""
with self._lock, self.flows._write_lock:
current = self.read(name, draft=True)
if base_version is not None and base_version != current.version:
raise StaleVersion(name, current.version)
draft = self._draft_file(name)
if draft.exists():
self._file(name).write_text(self._dump(current))
draft.unlink()
self.flows._commit(f"Publish dashboard '{name}'")
return current.model_copy(update={"has_draft": False})
def discard_draft(self, name: str) -> DashboardDef:
"""Throw the unpublished changes away and go back to what is shown."""
with self._lock, self.flows._write_lock:
draft = self._draft_file(name)
if draft.exists():
draft.unlink()
self.flows._commit(f"Discard draft of dashboard '{name}'")
return self.read(name)
def delete(self, name: str) -> None:
if not self.exists(name):
raise DashboardNotFound(name)
path = self._file(name)
with self.flows._write_lock:
path.unlink(missing_ok=True)
self._draft_file(name).unlink(missing_ok=True)
try:
path.parent.rmdir()
except OSError:
pass
self.flows._commit(f"Delete dashboard '{name}'")
def rename(self, name: str, new_name: str) -> DashboardDef:
defn = self.read(name, draft=True)
if self.exists(new_name):
raise DashboardExists(new_name)
with self.flows._write_lock:
renamed = defn.model_copy(update={"name": new_name})
self._file(new_name).parent.mkdir(parents=True, exist_ok=True)
# Whichever files the dashboard has move; one nobody published yet
# has only the draft, and renaming it must not publish it.
if self.is_published(name):
published = self.read(name).model_copy(update={"name": new_name})
self._file(new_name).write_text(self._dump(published))
self._file(name).unlink()
# An unpublished edit belongs to the dashboard, so it moves too.
if self.has_draft(name):
self._draft_file(new_name).write_text(self._dump(renamed))
self._draft_file(name).unlink()
try:
self._file(name).parent.rmdir()
except OSError:
pass
self.flows._commit(f"Rename dashboard '{name}' to '{new_name}'")
return renamed
def bindings_for(self, flow: str) -> Bindings:
"""Every widget bound to a message of ``flow``.
What the canvas draws as an endpoint: a control that sets one of this
flow's messages, or a tile that shows one. Without this a dashboard is
an invisible participant — a value changes and nothing on the canvas
accounts for it.
"""
prefix = f"{flow}."
found: Bindings = []
for path in sorted(self.root.glob("*/dashboard.json")):
try:
defn = DashboardDef.model_validate_json(path.read_text())
except Exception:
continue
# A bound setting is a consumer too — the dashboard itself reading
# a message rather than any tile on it — so the canvas accounts for
# it the same way. ``widget`` is what the endpoint id is built
# from, and no widget id can collide with it: a dot is not a legal
# name character.
for name, setting in defn.settings.items():
if not setting.message.startswith(prefix):
continue
found.append(
{
"dashboard": defn.name,
"dashboard_title": defn.title or defn.name,
"widget": f"settings.{name}",
"title": f"{defn.title or defn.name} {name}",
"type": "setting",
"provides": "",
"requires": [setting.message],
}
)
for widget in defn.widgets:
# A control produces the message; a tile consumes it.
produces = widget.target if widget.target.startswith(prefix) else ""
consumes = [m for m in widget.messages if m.startswith(prefix)]
if not produces and not consumes:
continue
found.append(
{
"dashboard": defn.name,
"dashboard_title": defn.title or defn.name,
"widget": widget.id,
"title": widget.title or widget.id,
"type": widget.type,
"provides": produces,
"requires": consumes,
}
)
return found
def history_requirements(self) -> dict[str, int]:
"""How many points to keep per message, so charts have a past to draw.
The deepest chart bound to a message wins; a message no chart reads
keeps the default.
"""
limits: dict[str, int] = {}
for path in self.root.glob("*/dashboard.json"):
try:
defn = DashboardDef.model_validate_json(path.read_text())
except Exception:
continue
for widget in defn.widgets:
points = widget.history_points
if not points:
continue
for message in widget.messages:
limits[message] = max(limits.get(message, 0), points)
return limits
def default_dashboard(name: str) -> DashboardDef:
"""A new dashboard: one page, one section, nothing in it yet."""
return DashboardDef(
name=name,
title=name.replace("_", " ").capitalize(),
)
#: What the generated dashboard is called, for a flow of this name.
def results_name(flow: str) -> str:
return f"{flow}_results"
#: Numbers a stat or a chart can draw.
_NUMERIC = {DType.FLOAT, DType.INT}
def results_dashboard(flow: FlowDef) -> DashboardDef:
"""A results dashboard for a batch flow, from the ports it declares.
A streaming output is a curve and gets a chart; a scalar output is a
number and gets a stat. Nothing here is specific to runs: the widgets bind
to the flow's own message names, which is what makes the same page draw a
run live, draw a finished one when opened in a run's context, and stay an
ordinary dashboard anyone can edit afterwards.
A starting point rather than a finished page — which is the only reason
generating one is worth doing at all.
"""
charts = [
spec
for node in flow.nodes
for spec in node.provides
if spec.stream and spec.dtype in _NUMERIC and spec.name
]
# What a run reports. Declared outputs are unqualified names; an empty list
# means "everything the flow ends up holding", which is not a set this can
# enumerate, so it draws no stats rather than guessing at them.
produced = {
spec.name.rsplit(".", 1)[-1]: spec
for node in flow.nodes
for spec in node.provides
if spec.name
}
stats = [
produced[name]
for name in flow.outputs
if name in produced and not produced[name].stream
]
widgets: list[WidgetDef] = []
for index, spec in enumerate(charts):
widgets.append(
WidgetDef(
id=f"chart_{spec.port or index}",
type="chart",
title=spec.port or spec.name,
layout={"lg": Placement(x=0, y=index * 4, w=8, h=4)},
config={
"series": [
{
"message": qualify(flow.name, spec.name),
"dtype": spec.dtype.value,
"label": spec.port or spec.name,
}
],
"history": {"points": 600},
},
)
)
row = 0
for spec in stats:
if spec.dtype is DType.RECORD:
kind: WidgetType = "notification"
elif spec.dtype in _NUMERIC or spec.dtype is DType.STR:
kind = "stat"
else:
# A list, a series or an artifact has no single reading to show.
continue
widgets.append(
WidgetDef(
id=f"out_{spec.port}",
type=kind,
title=spec.port,
layout={"lg": Placement(x=8, y=row * 2, w=4, h=2)},
config={
"message": qualify(flow.name, spec.name),
"dtype": spec.dtype.value,
},
)
)
row += 1
return DashboardDef(
name=results_name(flow.name),
title=f"{flow.title or flow.name} results",
widgets=widgets,
)
__all__ = [
"BAR_ROWS",
"RUN_LINES",
"COLOR_DTYPES",
"DASHBOARD_DIR",
"HISTORY_CAP",
"INPUT_WIDGETS",
"SETTING_DTYPES",
"WIDGET_DTYPES",
"DashboardDef",
"DashboardExists",
"DashboardNotFound",
"DashboardStore",
"DashboardSummary",
"DashboardsPublic",
"Placement",
"SettingDef",
"WidgetDef",
"WidgetFootprint",
"default_dashboard",
]