"""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) @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 the widgets of 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". """ 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 for widget in defn.widgets: names.update(widget.messages) if widget.target: names.add(widget.target) return names