Files
app/backend/fluksio/flow/dashboards.py
T

1243 lines
49 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_id, _validate_name
from fluksio.flow.store import (
FlowStore,
IdTaken,
StaleVersion,
_updated_at,
_version,
checked,
claim,
copy_name,
named,
new_id,
read_keys,
)
#: 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
#: How many counters one counter widget links into its number. Mirrored in the
#: client (``ui/core/counter.ts``, ``MAX_COUNTERS``).
COUNTERS = 3
#: How many windows one querying chart offers, stacked down the tile's edge.
#: Mirrored in the client (``ChartWidget.tsx``, ``MAX_RANGES``).
CHART_RANGES = 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",
"buttons",
"switch",
"slider",
"input",
"dropdown",
"color",
"counter",
]
#: 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",
"buttons",
"switch",
"slider",
"input",
"dropdown",
"color",
"counter",
}
#: 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"},
# One number, the digits of which its counters are.
"counter": {"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
#: Where a widget's config names a message, as paths into it
#: (`namespace.rebind`), each with the accessor of `WidgetDef` that reads it. A
#: rename rewrites exactly these, and leaves titles, labels and every other
#: config string alone, so a binding the accessors read and this does not list
#: would keep its old name. A registry test holds the two to each other.
WIDGET_BINDINGS: tuple[tuple[str, ...], ...] = (
("message",), # `messages`, and a bar's first row (`bar_rows`)
("target",), # `target`
("request",), # `target`, of a querying chart
("series", "*", "message"), # `messages`, of a chart
("rows", "*", "message"), # `bar_rows`
("inner",), # `inner_bindings`, as one name
("inner", "*", "message"), # `inner_bindings`, as a list
("reading", "message"), # `reading`
("runs", "metric"), # a chart of runs: a recorded series, named as a message
)
#: The same for a whole stored document: the bound settings
#: (`DashboardDef.setting_messages`), the widgets, and the widgets of a
#: document still written as pages and sections (`_flatten_pages`).
DASHBOARD_BINDINGS: tuple[tuple[str, ...], ...] = (
("settings", "*", "message"),
*(("widgets", "*", "config", *path) for path in WIDGET_BINDINGS),
*(
("pages", "*", "sections", "*", "widgets", "*", "config", *path)
for path in WIDGET_BINDINGS
),
)
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, ranges}``: 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``.
``ranges`` lists the windows its rail offers, up to ``CHART_RANGES`` of
``{label, range_s, interval_s}``, and ``range_s`` names the one it opens on.
Without them it offers 1h, 6h, 24h and 7d. ``range_s: 0`` asks for the
whole history, and the flow answering decides how far back that reaches.
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.
A slider may also show what it is setting: ``reading`` binds
``{message, dtype, label}`` — a room's temperature beside its setpoint —
held to the same numbers its target is.
A counter publishes one number to its target, of which its ``counters``
are the digits: up to three ``{label, step, size, min, max}``, coarsest
first, so hours and minutes publish 07:30 as 450. ``wrap`` says whether it
wraps round at its ends; ``docs/interface/dashboards.md`` has the
arithmetic.
"""
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 reading(self) -> dict[str, Any]:
"""What a slider reads beside the value it sets; empty for anything else."""
reading = self.config.get("reading") if self.type == "slider" else None
return reading if isinstance(reading, dict) else {}
@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")
]
names = [self.config.get("message"), self.reading.get("message")]
return [str(name) for name in names if name]
@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 ""),
str(self.reading.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}'"
)
ranges = self.config.get("ranges") or []
if not isinstance(ranges, list) or len(ranges) > CHART_RANGES:
raise ValueError(
f"a querying chart offers a list of at most {CHART_RANGES} ranges"
)
for entry in ranges:
window = entry if isinstance(entry, dict) else {}
label = window.get("label")
span, step = window.get("range_s"), window.get("interval_s")
# `type() is int`, because a bool is an int to isinstance.
if not (
isinstance(label, str)
and label.strip()
and type(span) is int
and span >= 0
and type(step) is int
and step > 0
):
raise ValueError(
"a querying chart's range needs a label, a whole range_s "
f"of 0 or more and a whole interval_s above 0, not {entry!r}"
)
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 == "counter" and len(self.config.get("counters") or []) > COUNTERS:
raise ValueError(f"a counter links at most {COUNTERS} counters")
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."""
#: Which dashboard this is, like a flow's (``FlowDef.id``), in an id space
#: of its own: the directory, never changed once given, the key for one
#: from before ids. Optional on the way in, always set on the way out.
id: str = ""
#: The key, unique among dashboards. Nothing binds to it, so it is a label.
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)
#: ``round`` draws the dashboard on the circle inscribed in its canvas, for
#: a round screen such as a rotary knob: one widget at a time, each a whole
#: face, in the order of ``widgets`` (``layout`` is not read). A circle has
#: one size, so a round canvas is square: its height is its width.
shape: Literal["rect", "round"] = "rect"
#: 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("id")
@classmethod
def _check_id(cls, value: str) -> str:
return _validate_id(value)
@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
@model_validator(mode="after")
def _check_shape(self) -> DashboardDef:
"""Square a round canvas."""
if self.shape == "round":
self.canvas_height = self.canvas_width
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 DashboardCreate(BaseModel):
"""A new dashboard: one empty grid under a key, filled in by saves after."""
#: Empty for an id the server issues, or one a superuser pins, as for a
#: flow (`FlowCreate`). Dashboards keep no history, so it only has to be free.
id: str = ""
name: str
#: Empty reads the key as words.
title: str = ""
@field_validator("id")
@classmethod
def _check_id(cls, value: str) -> str:
return _validate_id(value)
@field_validator("name")
@classmethod
def _check_name(cls, value: str) -> str:
return _validate_name(value)
class DashboardSummary(BaseModel):
"""A dashboard in a list, without its contents."""
id: str = ""
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
#: A round dashboard's widgets are faces rather than tiles, so a list
#: draws them as a circle instead of at the grid places they carry.
shape: Literal["rect", "round"] = "rect"
#: 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
def __str__(self) -> str:
return f"There is already a dashboard named '{self.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()
#: Published documents as last read, until the flow store commits.
self._published: dict[str, DashboardDef | None] = {}
self._published_at = -1
self._published_lock = threading.Lock()
#: `key_index` and the flow store revision it was read at.
self._keys: tuple[int, dict[str, str]] = (-1, {})
def _file(self, name: str) -> Path:
return self.root / checked(name) / "dashboard.json"
def _draft_file(self, name: str) -> Path:
return self.root / checked(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(
id=defn.id,
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,
shape=defn.shape,
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)
defn = DashboardDef.model_validate_json(path.read_text())
# One from before ids is known by its directory, which is its key.
return defn.model_copy(
update={"id": defn.id or name, "has_draft": self.has_draft(name)}
)
def key_index(self) -> dict[str, str]:
"""Every dashboard's key, to its id, whether published or only a draft.
Read again after any commit of the store, as `FlowStore.key_index`.
Shared: read it, never change it.
"""
at, keys = self._keys
if at == self.flows.revision:
return keys
at = self.flows.revision
found = {p.parent for p in self.root.glob("*/dashboard.json")}
found |= {p.parent for p in self.root.glob("*/dashboard.draft.json")}
keys = read_keys(sorted(found), "dashboard")
self._keys = (at, keys)
return keys
def _claim(self, defn: DashboardDef, create: bool = False) -> DashboardDef:
"""*defn* with its id, once it is certain to be a save the store allows.
Under the flow store's write lock; see `claim`. A save is to a
dashboard that exists, named by its id or, from before ids, its key.
Only *create* may make a new one.
"""
dash_id = checked(defn.id or defn.name)
current = self.read(dash_id, draft=True) if self.exists(dash_id) else None
if current is None and not create:
raise DashboardNotFound(dash_id)
claim(
dash_id,
defn.name,
(current.id, current.name) if current else None,
self.key_index(),
DashboardExists,
)
return defn.model_copy(update={"id": dash_id})
def published(self, name: str) -> DashboardDef | None:
"""The published document, or None, as of the store's last commit.
What a device's credential is held against on every request, a camera
fetching frame by frame included, so it is read once per commit rather
than from disk each time. Every write commits. The document is shared:
read it, never change it.
"""
with self._published_lock:
if self._published_at != self.flows.revision:
self._published, self._published_at = {}, self.flows.revision
if name not in self._published:
try:
self._published[name] = self.read(name)
except DashboardNotFound:
self._published[name] = None
return self._published[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, and
creates one too, under the id it carries or else its key.
"""
with self._lock, self.flows._write_lock:
defn = self._claim(defn, create=True)
path = self._file(defn.id)
current = _version(path)
if base_version is not None and base_version != current:
raise StaleVersion(defn.name, current)
# Above the draft too, which an editor may be holding.
top = max(current, _version(self._draft_file(defn.id)))
saved = defn.model_copy(update={"version": top + 1, "has_draft": False})
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(self._dump(saved))
self.flows._commit(f"Save dashboard {named(defn.name, defn.id)}")
return saved
def create(
self, defn: DashboardDef, pin: str = "", published: bool = False
) -> DashboardDef:
"""A new dashboard, only a draft unless *published* says otherwise.
Its id is issued here unless *pin* names one, a superuser's to ask for
as for a flow (`FlowStore.create`). Dashboards have no history to
adopt, so a pin only has to be free.
"""
with self._lock, self.flows._write_lock:
keys = self.key_index()
if pin:
dash_id = checked(pin)
if (self.root / dash_id).exists():
raise IdTaken(dash_id, "dashboard")
else:
dash_id = new_id("d", lambda c: (self.root / c).exists() or c in keys)
defn = self._claim(defn.model_copy(update={"id": dash_id}), create=True)
if keys.get(dash_id, dash_id) != dash_id:
raise IdTaken(dash_id, "dashboard")
saved = defn.model_copy(update={"version": 1, "has_draft": not published})
path = self._file(dash_id) if published else self._draft_file(dash_id)
path.parent.mkdir(parents=True)
path.write_text(self._dump(saved))
self.flows._commit(f"Create dashboard {named(defn.name, dash_id)}")
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. The dashboard has to
exist: creating one is `create`.
"""
with self._lock, self.flows._write_lock:
defn = self._claim(defn)
current = self.read(defn.id, 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})
self._draft_file(defn.id).write_text(self._dump(saved))
self.flows._commit(f"Update draft of dashboard {named(defn.name, defn.id)}")
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 {named(current.name, 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.
What is shown comes back a version above the draft, as a flow's does
(`FlowStore.discard_draft`).
"""
with self._lock, self.flows._write_lock:
draft = self._draft_file(name)
if draft.exists():
shown = self.read(name)
top = _version(draft)
if shown.version <= top:
self._file(name).write_text(
self._dump(shown.model_copy(update={"version": top + 1}))
)
draft.unlink()
shown = self.read(name)
self.flows._commit(f"Discard draft of dashboard {named(shown.name, name)}")
return shown
def delete(self, name: str) -> None:
if not self.exists(name):
raise DashboardNotFound(name)
path = self._file(name)
with self.flows._write_lock:
key = next((k for k, i in self.key_index().items() if i == name), name)
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 {named(key, name)}")
def duplicate(self, name: str) -> DashboardDef:
"""Copy the working copy into a new dashboard, as a draft only.
Under an id of its own and the key ``{name}_copy``, then ``_copy_2``
and on. Unpublished like a new dashboard, so no panel shows it until
someone publishes it.
"""
with self._lock, self.flows._write_lock:
source = self.read(name, draft=True)
keys = self.key_index()
new_name = copy_name(source.name, lambda n: n in keys)
copy_id = new_id("d", lambda c: (self.root / c).exists() or c in keys)
copy = source.model_copy(
update={
"id": copy_id,
"name": new_name,
"title": f"{source.title} (copy)" if source.title else "",
"version": 1,
"has_draft": True,
}
)
copy = self._claim(copy, create=True)
path = self._draft_file(copy_id)
path.parent.mkdir(parents=True)
path.write_text(self._dump(copy))
self.flows._commit(
f"Duplicate dashboard {named(source.name, name)} as "
f"{named(new_name, copy_id)}"
)
return copy
def bindings_for(self, flow: str) -> Bindings:
"""Every widget bound to a message of the flow with key ``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
# By id, which is what opening it takes; the title is the label.
dash_id = defn.id or path.parent.name
# 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": dash_id,
"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": dash_id,
"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, title: str = "") -> DashboardDef:
"""A new dashboard: one empty grid, titled after its key unless told."""
return DashboardDef(
name=name,
title=title or 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",
"DashboardCreate",
"DashboardDef",
"DashboardExists",
"DashboardNotFound",
"DashboardStore",
"DashboardSummary",
"DashboardsPublic",
"Placement",
"SettingDef",
"WidgetDef",
"WidgetFootprint",
"default_dashboard",
]