Panels: per-device dashboard sets, paired by code
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s

A panel is one screen and the ordered set of whole dashboards it shows, so a
hallway tablet and a workshop tablet carry different sets without either
dashboard knowing about the other. More than one and the device draws a rail
to switch between them — the same rail the editor puts on screen, because the
wall has it and it takes room off the canvas.

A screen has no keyboard, so it pairs rather than logs in: it shows a
six-character code, somebody approves it against a panel from the dashboards
overview, and the credential that mints reaches that panel's published
dashboards and the message endpoints its widgets speak, and nothing else.
Deleting the panel revokes it.

Closes the per-device view and the kiosk credential; supersedes the
multi-page/multi-section UI, since a page is now a dashboard of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHpLJHozysQXjsxAyU1WHj
This commit is contained in:
2026-08-20 16:23:36 +02:00
co-authored by Claude Opus 5
parent eae6758c5a
commit c0ff1cd5c5
26 changed files with 1696 additions and 40 deletions
+74 -5
View File
@@ -12,6 +12,7 @@ from app.cloud import config as cloud_config
from app.core import security
from app.core.config import settings
from app.core.db import engine
from app.flow import panels
from app.flow.controller import FlowController
from app.flow.dashboards import DashboardStore
from app.flow.workers import PythonWorkerPool
@@ -31,7 +32,57 @@ SessionDep = Annotated[Session, Depends(get_db)]
TokenDep = Annotated[str, Depends(reusable_oauth2)]
def decode_token(token: str) -> dict[str, Any]:
#: What a `draft` query parameter has to say to mean "no". Pydantic's own false
#: literals; anything else is refused rather than guessed at.
_NOT_DRAFT = {"false", "0", "off", "f", "n", "no", ""}
def _panel_may(payload: dict[str, Any], request: Request) -> None:
"""Refuse anything a wall panel has no business asking for.
A panel credential names the account that approved the pairing, so without
this it would be that person's session hanging on a wall. What a panel
genuinely needs is small and worth writing out: the dashboards it was
assigned, its own definition, and the message endpoints its widgets speak.
Publishing a message is in the list because a panel cannot be strictly
read-only — a querying chart asks for its window by publishing the request
— and a control on a panel is the point of putting one there.
"""
panel = panels.find(str(payload.get("panel", "")))
if panel is None:
# Deleting a panel is how its credential is revoked, so a token naming
# one that is gone is a token to stop trusting.
raise InvalidTokenError("This panel no longer exists")
api = settings.API_V1_STR
path = request.url.path
method = request.method
allowed = False
if method == "GET" and path.startswith(f"{api}/dashboards/"):
name = path[len(f"{api}/dashboards/") :]
# One segment only: a dashboard it shows, published — never a draft,
# and never a sibling route like `/publish`. The flag is read the way
# the endpoint reads it rather than by presence, because the generated
# client spells the default out as `?draft=false` — and it is read
# fail-closed, so a spelling neither side recognises is a draft.
draft = request.query_params.get("draft")
published = draft is None or draft.lower() in _NOT_DRAFT
allowed = "/" not in name and name in panel.dashboards and published
elif method == "GET" and path == f"{api}/panels/{panel.id}":
allowed = True
elif method in ("GET", "POST") and path.startswith(f"{api}/messages/"):
allowed = True
if not allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="A panel credential cannot do that",
)
def decode_token(token: str, request: Request | None = None) -> dict[str, Any]:
"""Read a bearer token, whichever channel issued it.
A browser session is signed with the app's own secret; an agent's token is
@@ -40,7 +91,13 @@ def decode_token(token: str) -> dict[str, Any]:
rights — the difference is only in who is holding it, which the MCP
endpoint checks separately.
The third branch is the seam a hosted deployment widens: a portal this
A paired wall panel's token is signed with the app's secret too, and is
told apart from a session by its audience: the session decode above
refuses it outright, so the only door it fits is the one ``_panel_may``
guards. Called without a request — from the websocket, which has no route
to scope — the panel branch checks only that the panel still exists.
The last branch is the seam a hosted deployment widens: a portal this
installation was enrolled with signs tokens with a key pinned at
enrolment, and they resolve to the local account that performed it. With
no enrolment the branch raises immediately, so an offline installation
@@ -53,6 +110,16 @@ def decode_token(token: str) -> dict[str, Any]:
return session
except InvalidTokenError:
pass
try:
panel_token = security.decode_panel_token(token)
except InvalidTokenError:
pass
else:
if request is not None:
_panel_may(panel_token, request)
elif panels.find(str(panel_token.get("panel", ""))) is None:
raise InvalidTokenError("This panel no longer exists")
return panel_token
try:
return security.decode_oauth_token(token)
except InvalidTokenError:
@@ -74,15 +141,17 @@ def user_from_token(session: Session, token: str) -> User | None:
return user
def get_current_user(session: SessionDep, token: TokenDep) -> User:
def get_current_user(request: Request, session: SessionDep, token: TokenDep) -> User:
"""Resolve the bearer token to its user.
Every failure here is an authentication failure, so all of them answer 401:
a token naming a user who no longer exists is a session to log in again,
not a missing resource to report.
not a missing resource to report. A panel credential reaching past what it
is allowed is the one exception — that is a 403 raised inside
``decode_token``, and it travels through here untouched.
"""
try:
token_data = TokenPayload(**decode_token(token))
token_data = TokenPayload(**decode_token(token, request))
except (InvalidTokenError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
+2
View File
@@ -11,6 +11,7 @@ from app.api.routes import (
modules,
oauth,
observability,
panels,
private,
runs,
secrets,
@@ -28,6 +29,7 @@ api_router.include_router(flows.ws_router)
api_router.include_router(secrets.router)
api_router.include_router(alerts.router)
api_router.include_router(dashboards.router)
api_router.include_router(panels.router)
api_router.include_router(messages.router)
api_router.include_router(modules.router)
api_router.include_router(observability.router)
+206
View File
@@ -0,0 +1,206 @@
"""Panels: which dashboards a device shows, and how it gets a credential.
A wall tablet has no keyboard, so it cannot log in. It asks for a code instead,
shows it on the wall, and somebody with an account types that code into the
panels dialog to say which panel the device is. The device polls, collects the
credential the approval minted, and never asks again.
The credential is scoped: ``app.api.deps`` lets it reach the dashboards that
panel was assigned and nothing else. Removing the panel revokes it.
"""
from __future__ import annotations
import secrets
import time
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from app.api.deps import CurrentUser, get_current_active_superuser, get_current_user
from app.core import security
from app.flow.panels import PanelDef, PanelsConfig, find, read_config, write_config
from app.models import Message
#: Gated per route rather than on the router: the two pairing endpoints are the
#: only unauthenticated ones in the app, because a device with no credential is
#: the whole point of them.
router = APIRouter(prefix="/panels", tags=["panels"])
#: Long, because a panel is a screen somebody hung once and left running, with
#: no keyboard to sign in again with. Same reasoning as a remote worker's.
TOKEN_DAYS = 365
#: How long a device's code is worth typing. Long enough to walk to a computer.
PAIR_TTL = 600
#: No I/O/0/1: the code is read off a wall and typed on a keyboard.
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
CODE_LENGTH = 6
#: Refuse to grow past this. Codes are free to ask for and cost memory.
MAX_PENDING = 50
class _Pending:
"""A device waiting to be told what it is."""
def __init__(self, secret_value: str) -> None:
self.secret = secret_value
self.expires = time.monotonic() + PAIR_TTL
self.token: str = ""
self.panel: str = ""
# ponytail: in-process, so pairing needs the API to be one process — which it
# is. Move to a table if it ever runs behind more than one worker.
_pending: dict[str, _Pending] = {}
def _prune() -> None:
now = time.monotonic()
for code in [c for c, p in _pending.items() if p.expires < now]:
del _pending[code]
class PairStarted(BaseModel):
"""What the device puts on the wall, and what it polls with."""
code: str
#: Held by the device, never shown. Without it the code alone would let
#: anyone who reads it off the wall collect the credential first.
secret: str
expires_in: int = PAIR_TTL
class PairStatus(BaseModel):
"""Nothing yet, or the credential and the panel it is for."""
access_token: str | None = None
panel: str | None = None
class PairRequest(BaseModel):
code: str
@router.get("/", response_model=PanelsConfig, dependencies=[Depends(get_current_user)])
async def read_panels() -> Any:
"""Every panel, and what each one shows."""
return await run_in_threadpool(read_config)
@router.put(
"/",
response_model=PanelsConfig,
dependencies=[Depends(get_current_active_superuser)],
)
async def save_panels(body: PanelsConfig) -> Any:
"""Replace the panels. Takes effect on the devices' next read.
A panel that disappears here takes its credential with it, so this is also
how a device is unpaired.
"""
seen = set()
for panel in body.panels:
if panel.id in seen:
raise HTTPException(
status_code=422, detail=f"Two panels named {panel.id!r}"
)
seen.add(panel.id)
await run_in_threadpool(write_config, body)
return body
@router.post("/pair", response_model=PairStarted)
def start_pairing() -> Any:
"""A device asks to be adopted. Unauthenticated, by necessity.
All this hands out is a code that means nothing until somebody with an
account approves it, so the worst an unwelcome caller achieves is a line in
a dictionary that expires ten minutes later.
"""
_prune()
if len(_pending) >= MAX_PENDING:
raise HTTPException(
status_code=429, detail="Too many devices are waiting to be paired"
)
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH))
while code in _pending:
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH))
entry = _Pending(secrets.token_urlsafe(16))
_pending[code] = entry
return PairStarted(code=code, secret=entry.secret)
@router.get("/pair/{code}", response_model=PairStatus)
def poll_pairing(code: str, secret: str = "") -> Any:
"""Has anyone claimed this device yet?
Answers the same 404 for a code that never existed, one that expired and
one polled with the wrong secret: a caller reading a code off a wall learns
nothing by guessing.
"""
_prune()
entry = _pending.get(code)
if entry is None or not secrets.compare_digest(entry.secret, secret):
raise HTTPException(
status_code=404, detail="No pairing is waiting on that code"
)
if not entry.token:
return PairStatus()
# Handed over once. A credential left lying in memory is a second copy of
# it, and the device has the only one it needs.
del _pending[code]
return PairStatus(access_token=entry.token, panel=entry.panel)
@router.post(
"/{panel_id}/pair",
response_model=Message,
dependencies=[Depends(get_current_active_superuser)],
)
def approve_pairing(panel_id: str, body: PairRequest, current_user: CurrentUser) -> Any:
"""Say which panel the device showing this code is.
The credential names the approver, so what the panel does stays
attributable to a person rather than to nobody.
"""
_prune()
if find(panel_id) is None:
raise HTTPException(status_code=404, detail=f"No panel named {panel_id!r}")
entry = _pending.get(body.code.strip().upper())
if entry is None:
raise HTTPException(
status_code=404,
detail="No device is waiting on that code — check it again",
)
entry.token = security.create_panel_token(
panel_id, current_user.id, timedelta(days=TOKEN_DAYS)
)
entry.panel = panel_id
return Message(message=f"Paired with {panel_id}")
@router.get(
"/{panel_id}", response_model=PanelDef, dependencies=[Depends(get_current_user)]
)
async def read_panel(panel_id: str) -> Any:
"""One panel — read by a person, or by the device holding its credential.
Declared after the pairing routes so a device polling ``/panels/pair/...``
is never mistaken for someone reading a panel named ``pair``.
"""
panel = await run_in_threadpool(find, panel_id)
if panel is None:
raise HTTPException(status_code=404, detail=f"No panel named {panel_id!r}")
return panel
+4
View File
@@ -44,6 +44,10 @@ class Settings(BaseSettings):
# Which failures reach which channel. Beside the flows, not in them:
# alerting is the deployment's concern, not any one flow's.
ALERTS_FILE: Path = Path("flow-data/alerts.json")
# Which dashboards each device shows. Beside the flows for the same reason
# alerting is: where a screen hangs is the deployment's concern rather than
# any one dashboard's.
PANELS_FILE: Path = Path("flow-data/panels.json")
# The MCP endpoint, and the OAuth server agents authenticate against. Off
# until someone asks for it: it opens client registration to the network.
MCP_ENABLED: bool = False
+41
View File
@@ -33,6 +33,10 @@ MCP_SCOPE = "mcp"
#: agent's token cannot attach a worker and a worker's cannot call the API.
WORKER_SCOPE = "worker"
WORKER_AUDIENCE = "fluksio-worker"
#: What a paired wall panel's credential says it is for. Its own audience, so
#: the session decode refuses it outright and the scope check in ``deps`` is
#: the only door it fits.
PANEL_AUDIENCE = "fluksio-panel"
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
@@ -169,6 +173,43 @@ def decode_worker_token(token: str) -> dict[str, Any]:
return payload
def create_panel_token(
panel: str, user_id: uuid.UUID | str, expires_delta: timedelta
) -> str:
"""The credential a paired wall panel holds.
Signed with the app's own secret like a browser session, because it names a
person in exactly the same way: ``sub`` is the account that approved the
pairing, so everything the panel does is attributable to them. What keeps
it from being a full session is the ``panel`` claim — the request filter in
``app.api.deps`` lets it reach only that panel's dashboards and the message
endpoints its widgets need.
Long-lived on purpose: a wall tablet is set up once and left running, and
it has no keyboard to log in again with.
"""
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"aud": PANEL_AUDIENCE,
"panel": panel,
"iat": now,
"exp": now + expires_delta,
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
def decode_panel_token(token: str) -> dict[str, Any]:
"""Validate a panel credential. Raises ``InvalidTokenError`` if it does not."""
payload: dict[str, Any] = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[ALGORITHM],
audience=PANEL_AUDIENCE,
)
return payload
def decode_oauth_token(token: str) -> dict[str, Any]:
"""Validate an MCP token. Raises ``InvalidTokenError`` if it does not hold."""
payload: dict[str, Any] = jwt.decode(
+77
View File
@@ -0,0 +1,77 @@
"""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 app.core.config import settings
from app.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
+202
View File
@@ -0,0 +1,202 @@
"""Panels: assignment, pairing, and what a paired credential may reach."""
from fastapi.testclient import TestClient
from app.core.config import settings
PREFIX = f"{settings.API_V1_STR}/panels"
DASHBOARDS = f"{settings.API_V1_STR}/dashboards"
def _panels(client: TestClient, headers: dict[str, str], config: dict) -> None:
response = client.put(f"{PREFIX}/", headers=headers, json=config)
assert response.status_code == 200, response.text
def _pair(client: TestClient, headers: dict[str, str], panel: str) -> dict[str, str]:
"""Walk a device through pairing and return the header it ends up with."""
started = client.post(f"{PREFIX}/pair").json()
waiting = client.get(
f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]}
)
assert waiting.json()["access_token"] is None
approved = client.post(
f"{PREFIX}/{panel}/pair", headers=headers, json={"code": started["code"]}
)
assert approved.status_code == 200, approved.text
collected = client.get(
f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]}
).json()
assert collected["panel"] == panel
return {"Authorization": f"Bearer {collected['access_token']}"}
def test_panels_require_authentication(client: TestClient) -> None:
assert client.get(f"{PREFIX}/").status_code == 401
assert client.put(f"{PREFIX}/", json={"panels": []}).status_code == 401
def test_assign_and_read_back(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
for name in ("hall_a", "hall_b"):
client.post(f"{DASHBOARDS}/{name}", headers=superuser_token_headers)
_panels(
client,
superuser_token_headers,
{
"panels": [
{"id": "hall", "title": "Hall", "dashboards": ["hall_a", "hall_b"]}
]
},
)
stored = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
assert stored["panels"][0]["dashboards"] == ["hall_a", "hall_b"]
assert (
client.get(f"{PREFIX}/hall", headers=superuser_token_headers).json()["title"]
== "Hall"
)
assert (
client.get(f"{PREFIX}/nowhere", headers=superuser_token_headers).status_code
== 404
)
def test_duplicate_panel_is_refused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
response = client.put(
f"{PREFIX}/",
headers=superuser_token_headers,
json={"panels": [{"id": "twice"}, {"id": "twice"}]},
)
assert response.status_code == 422
def test_polling_needs_the_secret(client: TestClient) -> None:
started = client.post(f"{PREFIX}/pair").json()
assert len(started["code"]) == 6
assert (
client.get(
f"{PREFIX}/pair/{started['code']}", params={"secret": "wrong"}
).status_code
== 404
)
assert (
client.get(f"{PREFIX}/pair/ZZZZZZ", params={"secret": "x"}).status_code == 404
)
def test_approving_an_unknown_code_or_panel_is_refused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
_panels(client, superuser_token_headers, {"panels": [{"id": "hall"}]})
assert (
client.post(
f"{PREFIX}/hall/pair",
headers=superuser_token_headers,
json={"code": "ZZZZZZ"},
).status_code
== 404
)
started = client.post(f"{PREFIX}/pair").json()
assert (
client.post(
f"{PREFIX}/nowhere/pair",
headers=superuser_token_headers,
json={"code": started["code"]},
).status_code
== 404
)
def test_paired_panel_reaches_only_what_it_shows(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
for name in ("panel_shown", "panel_hidden"):
client.post(f"{DASHBOARDS}/{name}", headers=superuser_token_headers)
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "hall", "dashboards": ["panel_shown"]}]},
)
panel_headers = _pair(client, superuser_token_headers, "hall")
# What it was assigned, published, plus its own definition and the messages
# its widgets speak.
assert (
client.get(f"{DASHBOARDS}/panel_shown", headers=panel_headers).status_code
== 200
)
assert client.get(f"{PREFIX}/hall", headers=panel_headers).status_code == 200
assert (
client.get(
f"{settings.API_V1_STR}/messages/", headers=panel_headers
).status_code
== 200
)
# The generated client spells the default out, so `?draft=false` is what a
# browser actually asks with for "the published one".
for spelling in ("false", "0", "off"):
assert (
client.get(
f"{DASHBOARDS}/panel_shown",
headers=panel_headers,
params={"draft": spelling},
).status_code
== 200
), spelling
# And nothing else.
assert (
client.get(f"{DASHBOARDS}/panel_hidden", headers=panel_headers).status_code
== 403
)
# Fail-closed: a spelling neither side recognises counts as a draft.
for spelling in ("true", "1", "yes", "maybe"):
assert (
client.get(
f"{DASHBOARDS}/panel_shown",
headers=panel_headers,
params={"draft": spelling},
).status_code
== 403
), spelling
assert client.get(f"{DASHBOARDS}/", headers=panel_headers).status_code == 403
assert (
client.get(f"{settings.API_V1_STR}/flows/", headers=panel_headers).status_code
== 403
)
assert (
client.delete(f"{DASHBOARDS}/panel_shown", headers=panel_headers).status_code
== 403
)
assert client.get(f"{PREFIX}/", headers=panel_headers).status_code == 403
def test_removing_the_panel_revokes_its_credential(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
client.post(f"{DASHBOARDS}/panel_gone", headers=superuser_token_headers)
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "workshop", "dashboards": ["panel_gone"]}]},
)
panel_headers = _pair(client, superuser_token_headers, "workshop")
assert (
client.get(f"{DASHBOARDS}/panel_gone", headers=panel_headers).status_code == 200
)
_panels(client, superuser_token_headers, {"panels": []})
# 401, not 403: there is nothing left to be forbidden from, and the device
# should go back to the pairing screen rather than retry.
assert (
client.get(f"{DASHBOARDS}/panel_gone", headers=panel_headers).status_code == 401
)
+1
View File
@@ -19,6 +19,7 @@ def flow_data(tmp_path_factory: pytest.TempPathFactory) -> Generator[None, None,
root = tmp_path_factory.mktemp("flow-data")
settings.FLOWS_DIR = root / "flows"
settings.SECRETS_FILE = root / "secrets.enc"
settings.PANELS_FILE = root / "panels.json"
yield