Files
app/backend/fluksio/flow/panels.py
T
stroblmeandClaude Opus 5 518231aa39
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m9s
Playwright Tests / test-playwright (2, 2) (push) Failing after 11s
pre-commit / pre-commit (push) Failing after 1m59s
Test Backend / test-backend (push) Failing after 2m28s
Compose Smoke Test / test-compose (push) Failing after 11s
Playwright Tests / merge-reports (push) Failing after 2m19s
Touch is a panel setting, and the rail grows with it
It described the wrong object. A dashboard is a document that may hang on a
hallway tablet and in a desk browser at the same time, and only one of those
has fingers on it — so the flag moves off `DashboardDef.settings` and onto
`PanelDef` as a plain bool, ticked in the Panels dialog. `useCanvasRoot` takes
it as an argument rather than reading the document, and `/panel/{id}` is the
only surface with a panel to ask.

Dropping the message binding with it is deliberate: nothing drove it, and a
flow deciding whether a screen has fingers on it was never the point. A stored
`settings.touch` is inert rather than migrated, which `_check_settings`
skipping unknown names already guaranteed.

The rail was the other half. It had no touch behaviour at all and its 40px
buttons met neither branch of the 44/32 rule. `[data-touch] .dui-rail{-item}`
in `ui/core/core.css` spends the padding and the gap on the buttons instead,
so they reach the 44px target and the rail comes out taller at exactly the
same width — `RAIL_INSET` never moves, and the arrangement under it does not
either.

Also closes the panels-dialog icon gap: `DashboardSummary` carries the `icon`
now, so the dialog draws each assigned dashboard's rail glyph beside its
checkbox. `initials()` went from three identical copies in the looks to one in
`Dashboard/icons.ts`, so the dialog and the rail fall back the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va7ExQDtuwKN7kNpHhWWNQ
2026-08-31 19:04:20 +02:00

130 lines
4.6 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 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 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