"""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 import threading from pathlib import Path from pydantic import BaseModel, Field, field_validator from fluksio.core.config import settings from fluksio.flow.dashboards import DashboardDef, 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) #: Bigger controls, a bigger rail and no hover states, for a screen that is #: touched rather than pointed at. It belongs to the device rather than to #: any dashboard: the same dashboard may hang on a hallway tablet and on a #: desk browser, and only one of them has fingers on it. touch: bool = False #: 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 instance knows about.""" panels: list[PanelDef] = Field(default_factory=list) #: Held across a read-modify-write of the panels file. #: #: Both `save_panels` and `unpair_panel` are one of those, and interleaving #: them silently undid a revocation: a save that read the file before an #: unpair wrote it put the old nonce back, and the screen that had just been #: unpaired kept working. The nonce carry-forward in the save handler was #: written to make that impossible, and the window between its read and its #: write is where it happened anyway. edit_lock = threading.Lock() 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 dashboards_for(panel_id: str, store: DashboardStore) -> list[DashboardDef]: """The published documents this panel shows, in rail order. Published, since that is what a panel draws. A name that no longer resolves is a dashboard someone deleted and is skipped, and a panel that is gone shows nothing. Handed back whole rather than walked here, because what a panel may do with a message depends on the document it came from — a dashboard that says it is locked entitles a screen to read it and not to touch it. """ panel = find(panel_id) if panel is None: return [] found: list[DashboardDef] = [] for name in panel.dashboards: try: found.append(store.read(name)) except DashboardNotFound: continue return found def messages_of(defn: DashboardDef) -> set[str]: """Every message one dashboard reads or writes. 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. """ names = set(defn.setting_messages) for widget in defn.widgets: names.update(widget.messages) if widget.target: names.add(widget.target) return names def requests_of(defn: DashboardDef) -> set[str]: """The publishes this dashboard makes in order to read. A querying chart asks a flow for the series it draws by publishing a request, so that publish is how the tile reads rather than something anyone touched. Every other message a dashboard sends comes from a control, which is what marking it read-only turns off — so this is what a locked dashboard is still entitled to send. """ return {w.target for w in defn.widgets if w.type == "chart" and w.target} 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, and empty for a panel that is gone — which is the same answer as "nothing". """ return { name for defn in dashboards_for(panel_id, store) for name in messages_of(defn) }