An MCP access token is a stateless JWT good until it expires, so deleting the client row revoked nothing already handed out — on the MCP endpoint or on the REST API, which takes the same token directly. Both doors now look the client up by the `client_id` the token has always carried, so tokens already in circulation are held to it too. A dashboard's `locked` setting stopped the client drawing a control and nothing else; the server took a publish from a panel showing it anyway. It now bounds the panel's write scope, resolved live where a flow drives the flag, exactly as the client resolves it. Reads are untouched — read-only is not blind — and so is a querying chart's request, which is how that tile reads rather than something anyone touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CL9zvnnvcp1mvA8o7impxk
163 lines
5.9 KiB
Python
163 lines
5.9 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
|
|
|
|
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)
|
|
}
|