"""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 pathlib import Path from typing import Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator from fluksio.flow.schemas import _validate_name from fluksio.flow.store import FlowStore, StaleVersion #: 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 readings a bar may nest inside its own. The limit is contrast, not #: layout: the segments share one fill token, because no slot of the chart ramp #: clears 3:1 against the outer one, and a fourth could not be told from its #: neighbour. Mirrored in the client (``BarWidget.tsx``). BAR_SEGMENTS = 3 #: 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", # Input "button", "switch", "slider", "input", "dropdown", "color", ] 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 installation 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"}, # An icon maps weather strings, bool hints and numbers alike, and a clock # binds nothing at all, so neither 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. "theme": "str", # Read-only: the input widgets stop publishing. "locked": "bool", } 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 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 installation 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 inner_bindings(self) -> Bindings: """A bar's nested readings, in either shape a document may carry them. One binding beside ``inner_dtype``, as a bar was written before it stacked, or an ordered list of ``{message, dtype}`` — so an older dashboard keeps drawing without being migrated first. """ inner = self.config.get("inner") if isinstance(inner, list): return [s for s in inner[:BAR_SEGMENTS] 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 messages(self) -> list[str]: """Every message name this widget reads.""" 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") ] name = self.config.get("message") # Only a bar nests further readings inside the one it draws. nested = [s.get("message") for s in self.inner_bindings] return [str(value) for value in (name, *nested) if value] @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: 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 [] ] return [ str(self.config.get("dtype") or ""), *(str(s.get("dtype") or "") for s in self.inner_bindings), ] @model_validator(mode="after") def _check_binding(self) -> WidgetDef: """Refuse a widget wired to a message it cannot carry.""" 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 inner = self.config.get("inner") if isinstance(inner, list) and len(inner) > BAR_SEGMENTS: raise ValueError(f"a bar nests at most {BAR_SEGMENTS} 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 class SectionDef(BaseModel): """A grid of widgets under a heading.""" id: str title: str = "" widgets: list[WidgetDef] = Field(default_factory=list) @field_validator("id") @classmethod def _check_id(cls, value: str) -> str: return _validate_name(value) class PageDef(BaseModel): """One tab of a dashboard.""" id: str title: str = "" #: A lucide icon name, or empty. icon: str = "" sections: list[SectionDef] = Field(default_factory=list) @field_validator("id") @classmethod def _check_id(cls, value: str) -> str: return _validate_name(value) 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 = "" pages: list[PageDef] = 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 @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 installation 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 widgets(self) -> list[WidgetDef]: return [w for p in self.pages for s in p.sections for w in s.widgets] @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] class DashboardSummary(BaseModel): """A dashboard in a list, without its contents.""" name: str title: str = "" page_count: int = 0 widget_count: int = 0 has_draft: bool = False #: Of the working copy, so publishing from a list needs no second read. version: int = 1 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, page_count=len(defn.pages), widget_count=len(defn.widgets), has_draft=defn.has_draft, version=defn.version, ) ) 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 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(), pages=[PageDef(id="main", title="Overview", sections=[SectionDef(id="main")])], ) __all__ = [ "BAR_SEGMENTS", "COLOR_DTYPES", "DASHBOARD_DIR", "HISTORY_CAP", "INPUT_WIDGETS", "SETTING_DTYPES", "WIDGET_DTYPES", "DashboardDef", "DashboardExists", "DashboardNotFound", "DashboardStore", "DashboardSummary", "DashboardsPublic", "PageDef", "Placement", "SectionDef", "SettingDef", "WidgetDef", "default_dashboard", ]