A dashboard could only ever receive as a set of tiles. This adds the dashboard itself as a receiver: `settings` maps a name to a value plus an optional binding. Unbound, the setting is simply its value — a wall panel that is always dark costs no flow. Bound, a flow drives it live and the value is the fallback. Two settings are wired: `theme` (system/light/dark) and `locked` (read-only). There is no schedule field on purpose — a node publishing to the bound message on a cron is what a schedule is here, which is the point of a channel. - `messages_for()` now walks a dashboard's bound settings as well as its widgets' bindings. Without this a paired screen is refused its own theme message, on the one surface the setting exists for; it bounds the socket too. - `locked` is gated in `usePublish`, so every control inherits it, and each control also draws itself disabled — a dead button reads as broken otherwise. The panel surface says Read-only in the corner. - The theme is a class on the dashboard's own surface, never the root: inside the app shell it must not flip the chrome. `.light` gains the tokens `.dark` already had (mirrored in the index repo) so both directions work on a subtree. - Settings bindings are type-checked from the document alone, the rule widget bindings follow, and mirrored on the server. - A bound setting is drawn on the flow canvas as a dashboard-level endpoint. - The demo's house flow now publishes `home.panel_theme`, which the demo dashboard's theme binds to: the panel goes dark after sunset, at no tile cost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
113 lines
3.8 KiB
Python
113 lines
3.8 KiB
Python
"""Panels: which dashboards a given device shows.
|
|
|
|
A wall tablet in the hall and one in the workshop want different dashboards,
|
|
and the same dashboard may hang on both. Rather than nesting pages inside a
|
|
dashboard, a panel names an ordered set of whole dashboards — each keeps its
|
|
own canvas, its own draft and its own version, and the device switches between
|
|
them through a rail.
|
|
|
|
Stored beside the flows rather than in them, like the alerting configuration:
|
|
which screen hangs where is the deployment's concern, not any one dashboard's.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
from fluksio.core.config import settings
|
|
from fluksio.flow.dashboards import DashboardNotFound, DashboardStore
|
|
from fluksio.flow.schemas import _validate_name
|
|
|
|
|
|
class PanelDef(BaseModel):
|
|
"""One device, and what it shows."""
|
|
|
|
id: str
|
|
title: str = ""
|
|
#: Ordered. The first one is what the device opens after pairing, and the
|
|
#: rail follows this order. A name that no longer resolves is simply a
|
|
#: dashboard someone deleted; the panel skips it.
|
|
dashboards: list[str] = Field(default_factory=list)
|
|
#: Which generation of credential this panel honours. A token names the
|
|
#: nonce it was minted at, so bumping this refuses the screen currently
|
|
#: hanging here and leaves the panel, its dashboards and their arrangement
|
|
#: exactly as they are — re-pairing one device without deleting anything.
|
|
#: Not settable from outside: a save carries the stored value forward.
|
|
nonce: int = 0
|
|
|
|
@field_validator("id")
|
|
@classmethod
|
|
def _check_id(cls, value: str) -> str:
|
|
return _validate_name(value)
|
|
|
|
|
|
class PanelsConfig(BaseModel):
|
|
"""Every panel this installation knows about."""
|
|
|
|
panels: list[PanelDef] = Field(default_factory=list)
|
|
|
|
|
|
def _path() -> Path:
|
|
return settings.PANELS_FILE
|
|
|
|
|
|
def read_config() -> PanelsConfig:
|
|
"""The stored panels, or none. Blocking."""
|
|
path = _path()
|
|
if not path.exists():
|
|
return PanelsConfig()
|
|
try:
|
|
return PanelsConfig.model_validate_json(path.read_text())
|
|
except Exception:
|
|
# A hand-edited file that no longer parses must not lock everyone out.
|
|
return PanelsConfig()
|
|
|
|
|
|
def write_config(config: PanelsConfig) -> None:
|
|
"""Blocking."""
|
|
path = _path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(config.model_dump_json(indent=2))
|
|
|
|
|
|
def find(panel_id: str) -> PanelDef | None:
|
|
"""The panel by that id, or None if it was removed.
|
|
|
|
Read from disk on every call: this is what makes deleting a panel revoke
|
|
its credential, so it has to see the current file rather than a cache.
|
|
"""
|
|
for panel in read_config().panels:
|
|
if panel.id == panel_id:
|
|
return panel
|
|
return None
|
|
|
|
|
|
def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
|
|
"""Every message this panel's dashboards read or write.
|
|
|
|
What a screen is entitled to see, as its own dashboards define it. Read
|
|
from the published documents, since that is what a panel draws, and empty
|
|
for a panel that is gone — which is the same answer as "nothing".
|
|
|
|
A dashboard's own bound settings count, not only its widgets': the theme a
|
|
panel is driven to is a message no tile on it draws, and a wall panel
|
|
refused its own theme message is the one surface the setting exists for.
|
|
"""
|
|
panel = find(panel_id)
|
|
if panel is None:
|
|
return set()
|
|
names: set[str] = set()
|
|
for dashboard in panel.dashboards:
|
|
try:
|
|
defn = store.read(dashboard)
|
|
except DashboardNotFound:
|
|
continue
|
|
names.update(defn.setting_messages)
|
|
for widget in defn.widgets:
|
|
names.update(widget.messages)
|
|
if widget.target:
|
|
names.add(widget.target)
|
|
return names
|