From c0ff1cd5c51b26aeca855ac7af0099813c425980 Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 20 Aug 2026 16:21:25 +0200 Subject: [PATCH] Panels: per-device dashboard sets, paired by code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01AHpLJHozysQXjsxAyU1WHj --- NOTEPAD.md | 17 +- ROADMAP.md | 11 +- backend/app/api/deps.py | 79 ++++- backend/app/api/main.py | 2 + backend/app/api/routes/panels.py | 206 +++++++++++++ backend/app/core/config.py | 4 + backend/app/core/security.py | 41 +++ backend/app/flow/panels.py | 77 +++++ backend/tests/api/routes/test_panels.py | 202 +++++++++++++ backend/tests/conftest.py | 1 + frontend/src/client/schemas.gen.ts | 104 +++++++ frontend/src/client/sdk.gen.ts | 137 ++++++++- frontend/src/client/types.gen.ts | 67 +++++ .../src/components/Common/OverviewToolbar.tsx | 6 +- .../components/Dashboard/DashboardEditor.tsx | 34 ++- .../src/components/Dashboard/PanelRail.tsx | 94 ++++++ .../src/components/Dashboard/PanelSurface.tsx | 32 ++ .../src/components/Dashboard/PanelsDialog.tsx | 275 ++++++++++++++++++ frontend/src/components/Dashboard/queries.ts | 37 +++ frontend/src/components/Flow/CanvasTitle.tsx | 4 + frontend/src/main.tsx | 11 + frontend/src/routeTree.gen.ts | 42 +++ .../src/routes/_layout/dashboards/index.tsx | 33 ++- frontend/src/routes/panel.$id.tsx | 93 ++++++ frontend/src/routes/panel.index.tsx | 92 ++++++ frontend/src/routes/view.$name.tsx | 35 +-- 26 files changed, 1696 insertions(+), 40 deletions(-) create mode 100644 backend/app/api/routes/panels.py create mode 100644 backend/app/flow/panels.py create mode 100644 backend/tests/api/routes/test_panels.py create mode 100644 frontend/src/components/Dashboard/PanelRail.tsx create mode 100644 frontend/src/components/Dashboard/PanelSurface.tsx create mode 100644 frontend/src/components/Dashboard/PanelsDialog.tsx create mode 100644 frontend/src/routes/panel.$id.tsx create mode 100644 frontend/src/routes/panel.index.tsx diff --git a/NOTEPAD.md b/NOTEPAD.md index e08b547..188f06a 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -13,10 +13,12 @@ should reopen it. ### To be sorted +- BUG/UI in the brain view: make the chasing circle animation running entirely in the gap between the ring and the node (using the full width) - FEAT/UI add animation to widgets; i.e. status of bars, gauges etc. should fade from one state to another. Multi-buttons (like "Mode" in the "Home" dashboard of the demo) should transition from one state to another; use inspiration for animations based on the google material guidelines - BUG when clicking "edit" in the "Home" dashboard of the demo on hub.fluksio.com, most of the panels disappear (only a handfull is left for actual edit) - CHORE/UI: the edge popover shows the same value twice — `MessageSparkline` falls through to a collapsed `ValuePreview` for a non-numeric value, and `EdgeInspector` then renders its own `ValuePreview defaultOpen` below it. Cosmetic; one of the two is redundant. -- BUG/UI in the e.g. the "PV yield model" demo are two nodes which takes inputs that were not produced by any other node (e.g. seed, noise, samples). Their value seem sto be hard coded but unchangable in the node panel. We should only allow for static parameters in the corresponding "Settings" section of a node. +- BUG/UI in the e.g. the "PV yield model" demo are two nodes which takes inputs that were not produced by any other node (e.g. seed, noise, samples). Their value seem sto be hard coded but unchangable in the node panel. We should only allow for static parameters in the corresponding "Settings" section of a node. Debug first where these static values came from. Then develop a concept which matches the philosophy of the project. We could a) go for the same global/local parameter thing which node red uses (Push-back from my side; this becomes hardly manageable on scale) b) only restrict to node-level static parameters (and later implement an overview of parameters as dedicated page) c) drop the "params" feature entirely and make nodes entirely parameter free (would cause users to create their own static parameters). . My personal vote is b) as it forces the atomic flow-style we want to have in the app and it makes the app truly scalable. Reusing the same parameter across multiple nodes would essentially mean having one node where the parameter is set and then just returning this parameter as an output which other nodes can then consume. This would mirror the getter/setter pattern from python. When resolving this, we could also consider dissolving the obscure "params" input to nodes entirely; i.e. static node paramters would become input variables just as any other variable that goes into a function +- FEAT/UI we should introduce a sync between the header of the python function and the node configuration; i.e. adding an input/output or static paramter would change the header of the python function and changing the python function header and vice versa - BUG/UI on flows like "House history" where the widget sets the range for the "draw the window" node to generate some data, the edges overlap the nodes. We should adjust the flow visualization to account for these cyclic behaviors - BUG/UI when enlarging the code editor of a node, the code editor should enlarge to the left (node settings remain on the right) so that the code editor fills the center of the screen with the node properties available next to it - BUG/UX the console/log panel does not show print output of nodes @@ -132,15 +134,9 @@ Decisions taken up front, because most items below depend on them: port. A second query stream through one node needs a second node. - FEAT/UI: the slider offers `step` now, but no tick labels — the `datalist` marks are unlabelled and drop out past fifty steps. -- FEAT/API: a kiosk credential for `/view/{name}`, so a panel is not a - logged-in browser session. Note it cannot be strictly read-only: a querying - chart publishes its request, so the token needs that one write scope. - FEAT/UI: per-dashboard theme — forced light, forced dark, or switched on a schedule. View mode inherits localStorage and the OS preference today, which a panel in a room has no way to set. NOTE: to solve this, we could introduce a general message sending to the overall dashboard (so far we only treat widgets in a dashboard as a receiver). We could e.g. have a toggle in the dashboard settings which says "propagate theme" which enables a field for defining a consume input (identical to a standard node input) and then a node can connect to this property by producing a corresponding message. This would nicely generalize to other dashboard settings later. This could later also serve as a security mechanism, i.e. the possibility to lock down dashboards remotely -- FEAT/UI: page navigation in view mode. `/view/{name}` renders the first page - and offers no way to reach the others; the editor side of this is the - multi-page item under *Dashboard follow-ups*. - CHORE/FLOW: porting the controls needs a declared writable message per control, since an input widget can only target what a flow declares. Consider a dashboard-input node so a flow states plainly that a value arrives from a @@ -159,7 +155,12 @@ as an em dash. - BUG/UI: shrinking the canvas silently clips whatever now falls past its bottom edge. `maxRows` only constrains a new drag, not a stored placement, so nothing warns and nothing offers to reflow. - CHORE/UX: dropping a widget also selects it, which opens its panel — which rescales the canvas the instant you let go. Correct, but it lurches; either leave the panel closed on a drag-release or animate the scale. - CHORE/UI: `ROW_HEIGHT` is a fixed 80px while column width follows the canvas, so a 1920-wide panel at 12 columns has 160×80 cells. If that reads too wide, the row height could derive from the canvas too. -- FEAT/UI: multi-page and multi-section dashboards have no UI. The backend has `PageDef`/`SectionDef` and rename; the editor only ever edits `sectionsOf(page)[0]`, so nothing can create a second page. +- CHORE/UI: multi-page and multi-section dashboards still have no UI, and now need none — a panel carries several whole dashboards instead, each with its own canvas and its own publish. `PageDef`/`SectionDef` stay in the schema and the editor still edits `sectionsOf(page)[0]`, so the page `Tabs` in `DashboardEditor` are dead until something writes a second page through the API. +- FEAT/UI: a panel does not notice being reassigned until it is reloaded — nothing pushes the panel document or a dashboard publish, so the rail is as stale as the last read. Same gap as the wallpanel hot-reload item above; one event on the bus would answer both. +- CHORE/API: a panel credential may publish *any* message, not only the ones its own widgets bind to — the allowlist is the `/messages/` prefix rather than a walk of the panel's widgets. Enough for a screen in a house; an installation where a panel sits somewhere less trusted would want the narrower check. +- CHORE/API: unpairing a device means deleting the panel. A per-panel nonce in the token, bumped on demand, would let one screen be re-paired without disturbing the assignment. +- CHORE/API: `POST /panels/pair` is unauthenticated and capped at fifty pending codes in one process. A second API worker would each keep their own dictionary, so pairing would work only when the poll lands on the process that minted the code. +- CHORE/UI: the rail draws two letters off the dashboard title. `PageDef` already stores a lucide icon name; a dashboard-level one would read better on a wall. - CHORE/UI: only `layout.lg` is ever written, and `md`/`sm` stay unwritten by decision — a phone stacks the widgets (`.widget-stacked`) rather than carrying an arrangement of its own, since arranging is not a phone feature. The keys stay in the schema for a panel that one day wants a second size. - PERF/UI: `ChartWidget` re-joins the whole table on every live value. Fine at IoT rates; at `HISTORY_CAP` × 5 series it should append into a ring buffer. - CHORE/UI: opening edit mode on a dashboard whose widgets predate placement writes the migrated positions immediately, bumping the version once. diff --git a/ROADMAP.md b/ROADMAP.md index 87e2366..7179c8d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -273,7 +273,16 @@ Shares components with the admin view. See `docs/architecture/structure.canvas` transport and credentials only: the InfluxDB node runs Flux handed to it and echoes the rest, and Python nodes either side build the query and shape the answer — which is what keeps the widget ignorant of the database -- [ ] Per-device view +- [x] Per-device view: 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 instead of logging in: it + shows a six-character code, somebody approves it against a panel from the + dashboards overview, and the credential that mints is scoped to that + panel's dashboards and the message endpoints its widgets speak. Deleting + the panel revokes it ## Phase 5 — Website and docs diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index ee01751..97829b4 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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, diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 1634249..c8ba553 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -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) diff --git a/backend/app/api/routes/panels.py b/backend/app/api/routes/panels.py new file mode 100644 index 0000000..09e62f2 --- /dev/null +++ b/backend/app/api/routes/panels.py @@ -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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b093a86..1c8a314 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 74d094a..c271c60 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -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( diff --git a/backend/app/flow/panels.py b/backend/app/flow/panels.py new file mode 100644 index 0000000..001cc1a --- /dev/null +++ b/backend/app/flow/panels.py @@ -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 diff --git a/backend/tests/api/routes/test_panels.py b/backend/tests/api/routes/test_panels.py new file mode 100644 index 0000000..1f6fe2f --- /dev/null +++ b/backend/tests/api/routes/test_panels.py @@ -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 + ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 9c9e370..78cc58f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 09df90d..aecc169 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -1950,6 +1950,110 @@ export const PageDef_OutputSchema = { description: 'One tab of a dashboard.' } as const; +export const PairRequestSchema = { + properties: { + code: { + type: 'string', + title: 'Code' + } + }, + type: 'object', + required: ['code'], + title: 'PairRequest' +} as const; + +export const PairStartedSchema = { + properties: { + code: { + type: 'string', + title: 'Code' + }, + secret: { + type: 'string', + title: 'Secret' + }, + expires_in: { + type: 'integer', + title: 'Expires In', + default: 600 + } + }, + type: 'object', + required: ['code', 'secret'], + title: 'PairStarted', + description: 'What the device puts on the wall, and what it polls with.' +} as const; + +export const PairStatusSchema = { + properties: { + access_token: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Access Token' + }, + panel: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Panel' + } + }, + type: 'object', + title: 'PairStatus', + description: 'Nothing yet, or the credential and the panel it is for.' +} as const; + +export const PanelDefSchema = { + properties: { + id: { + type: 'string', + title: 'Id' + }, + title: { + type: 'string', + title: 'Title', + default: '' + }, + dashboards: { + items: { + type: 'string' + }, + type: 'array', + title: 'Dashboards' + } + }, + type: 'object', + required: ['id'], + title: 'PanelDef', + description: 'One device, and what it shows.' +} as const; + +export const PanelsConfigSchema = { + properties: { + panels: { + items: { + '$ref': '#/components/schemas/PanelDef' + }, + type: 'array', + title: 'Panels' + } + }, + type: 'object', + title: 'PanelsConfig', + description: 'Every panel this installation knows about.' +} as const; + export const PlacementSchema = { properties: { x: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 6f83928..187eb5b 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; +import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; export class AlertsService { /** @@ -1392,6 +1392,141 @@ export class ObservabilityService { } } +export class PanelsService { + /** + * Read Panels + * Every panel, and what each one shows. + * @returns PanelsConfig Successful Response + * @throws ApiError + */ + public static readPanels(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/panels/' + }); + } + + /** + * Save Panels + * 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. + * @param data The data for the request. + * @param data.requestBody + * @returns PanelsConfig Successful Response + * @throws ApiError + */ + public static savePanels(data: PanelsSavePanelsData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/panels/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Start Pairing + * 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. + * @returns PairStarted Successful Response + * @throws ApiError + */ + public static startPairing(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/panels/pair' + }); + } + + /** + * Poll Pairing + * 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. + * @param data The data for the request. + * @param data.code + * @param data.secret + * @returns PairStatus Successful Response + * @throws ApiError + */ + public static pollPairing(data: PanelsPollPairingData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/panels/pair/{code}', + path: { + code: data.code + }, + query: { + secret: data.secret + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Approve Pairing + * 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. + * @param data The data for the request. + * @param data.panelId + * @param data.requestBody + * @returns Message Successful Response + * @throws ApiError + */ + public static approvePairing(data: PanelsApprovePairingData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/panels/{panel_id}/pair', + path: { + panel_id: data.panelId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Panel + * 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``. + * @param data The data for the request. + * @param data.panelId + * @returns PanelDef Successful Response + * @throws ApiError + */ + public static readPanel(data: PanelsReadPanelData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/panels/{panel_id}', + path: { + panel_id: data.panelId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + export class PrivateService { /** * Create User diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 78cd8a2..c2945d9 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -707,6 +707,43 @@ export type PageDef_Output = { sections?: Array; }; +export type PairRequest = { + code: string; +}; + +/** + * What the device puts on the wall, and what it polls with. + */ +export type PairStarted = { + code: string; + secret: string; + expires_in?: number; +}; + +/** + * Nothing yet, or the credential and the panel it is for. + */ +export type PairStatus = { + access_token?: (string | null); + panel?: (string | null); +}; + +/** + * One device, and what it shows. + */ +export type PanelDef = { + id: string; + title?: string; + dashboards?: Array<(string)>; +}; + +/** + * Every panel this installation knows about. + */ +export type PanelsConfig = { + panels?: Array; +}; + /** * Where a widget sits in its section's grid, in grid units. */ @@ -1356,6 +1393,36 @@ export type ObservabilityReadDeadLettersData = { export type ObservabilityReadDeadLettersResponse = (Array); +export type PanelsReadPanelsResponse = (PanelsConfig); + +export type PanelsSavePanelsData = { + requestBody: PanelsConfig; +}; + +export type PanelsSavePanelsResponse = (PanelsConfig); + +export type PanelsStartPairingResponse = (PairStarted); + +export type PanelsPollPairingData = { + code: string; + secret?: string; +}; + +export type PanelsPollPairingResponse = (PairStatus); + +export type PanelsApprovePairingData = { + panelId: string; + requestBody: PairRequest; +}; + +export type PanelsApprovePairingResponse = (Message); + +export type PanelsReadPanelData = { + panelId: string; +}; + +export type PanelsReadPanelResponse = (PanelDef); + export type PrivateCreateUserData = { requestBody: PrivateUserCreate; }; diff --git a/frontend/src/components/Common/OverviewToolbar.tsx b/frontend/src/components/Common/OverviewToolbar.tsx index 1295d4b..287d93f 100644 --- a/frontend/src/components/Common/OverviewToolbar.tsx +++ b/frontend/src/components/Common/OverviewToolbar.tsx @@ -1,7 +1,7 @@ import { useMutation } from "@tanstack/react-query" import { Check, Loader2, Plus, Search } from "lucide-react" import { motion } from "motion/react" -import { useRef, useState } from "react" +import { type ReactNode, useRef, useState } from "react" import { Button } from "@/components/ui/button" import { DialogTrigger } from "@/components/ui/dialog" @@ -38,6 +38,7 @@ export function OverviewToolbar({ draftCount, publishing, onPublishAll, + children, }: { search: string onSearch: (value: string) => void @@ -49,6 +50,8 @@ export function OverviewToolbar({ draftCount: number publishing: boolean onPublishAll: () => void + /** Anything this particular overview adds, drawn ahead of the shared icons. */ + children?: ReactNode }) { const [open, setOpen] = useState(false) const trigger = useRef(null) @@ -62,6 +65,7 @@ export function OverviewToolbar({ return (
+ {children} {open ? ( widget.id === selected) ?? null const panelOpen = Boolean(active) || settingsOpen + // A dashboard hanging on a panel beside others is drawn with a rail over it, + // which takes room off the canvas. Show that here rather than letting someone + // arrange against a width the wall does not have. + // ponytail: the first such panel wins — the others differ only in scale. + const { data: panels } = useQuery(panelsQueryOptions()) + const railDashboards = + (panels?.panels ?? []).find( + (panel) => + (panel.dashboards ?? []).length > 1 && + (panel.dashboards ?? []).includes(draft.name), + )?.dashboards ?? null + const setEdit = (next: boolean) => { setSelected(null) setSettingsOpen(false) @@ -442,11 +456,28 @@ export function DashboardEditor({ return ( <> + {railDashboards && !stacked ? ( + ({ + to: "/dashboards/$name", + params: { name }, + search: edit ? { edit: true } : {}, + })} + /> + ) : null} +
{body} @@ -457,6 +488,7 @@ export function DashboardEditor({ "pointer-events-none absolute inset-0 transition-[right] duration-200", panelOpen && "md:right-[27rem]", )} + style={railDashboards && !stacked ? { left: RAIL_INSET } : undefined} > diff --git a/frontend/src/components/Dashboard/PanelRail.tsx b/frontend/src/components/Dashboard/PanelRail.tsx new file mode 100644 index 0000000..77338c1 --- /dev/null +++ b/frontend/src/components/Dashboard/PanelRail.tsx @@ -0,0 +1,94 @@ +import { useQueries } from "@tanstack/react-query" +import { Link, type LinkProps } from "@tanstack/react-router" + +import { dashboardQueryOptions } from "@/components/Dashboard/queries" +import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" + +/** How much room the rail takes, canvas insets included. Mirrors the side + * panel's `27rem`: the button plus the gutters either side of it. */ +export const RAIL_INSET = "4.5rem" + +/** Two letters off the title, so a rail of four reads as four different things. */ +function initials(label: string): string { + const words = label.split(/[\s_-]+/).filter(Boolean) + if (words.length === 0) return "?" + if (words.length === 1) return words[0].slice(0, 2).toUpperCase() + return (words[0][0] + words[1][0]).toUpperCase() +} + +/** + * Switching between the dashboards one panel was assigned. + * + * Permanent rather than a menu behind a button: a wall panel is glanced at, + * and something you have to open first is something nobody opens. It costs + * about a fortieth of the canvas, which `CanvasSurface` absorbs by scaling — + * the grid itself never changes, so an arrangement made without the rail still + * fits with it. + * + * Reading the dashboards it links to is also what fills the labels, and it + * warms the cache for the neighbours so a switch draws immediately. + */ +export function PanelRail({ + dashboards, + current, + linkFor, + className, +}: { + dashboards: string[] + current: string + linkFor: (name: string) => LinkProps + className?: string +}) { + const titles = useQueries({ + queries: dashboards.map((name) => dashboardQueryOptions(name)), + combine: (results) => + results.map((result, index) => result.data?.title || dashboards[index]), + }) + + return ( + + ) +} diff --git a/frontend/src/components/Dashboard/PanelSurface.tsx b/frontend/src/components/Dashboard/PanelSurface.tsx new file mode 100644 index 0000000..9f7efda --- /dev/null +++ b/frontend/src/components/Dashboard/PanelSurface.tsx @@ -0,0 +1,32 @@ +import type { Dashboard } from "@/components/Dashboard/DashboardView" +import { + CanvasSurface, + DashboardView, +} from "@/components/Dashboard/DashboardView" + +/** + * One dashboard filling whatever screen it landed on. + * + * The whole of what a wall panel draws, shared by the single-dashboard route + * (`/view/{name}`) and the paired-panel one (`/panel/{id}`) so a device shows + * the same thing either way — the second merely has a rail beside it. + */ +export function PanelSurface({ + dashboard, + stacked, +}: { + dashboard: Dashboard + /** A landscape arrangement scaled onto a phone comes out at about a fifth of + * its size, which reads as nothing at all. Stack it instead. */ + stacked?: boolean +}) { + if (stacked) return + + // The panel's own surface, scaled to fit. No dots: nothing is being + // arranged here. + return ( + + {() => } + + ) +} diff --git a/frontend/src/components/Dashboard/PanelsDialog.tsx b/frontend/src/components/Dashboard/PanelsDialog.tsx new file mode 100644 index 0000000..1bdc663 --- /dev/null +++ b/frontend/src/components/Dashboard/PanelsDialog.tsx @@ -0,0 +1,275 @@ +import { useMutation, useQuery } from "@tanstack/react-query" +import { Trash2 } from "lucide-react" +import { useState } from "react" + +import { + type ApiError, + type PanelDef, + type PanelsConfig, + PanelsService, +} from "@/client" +import { + dashboardsQueryOptions, + panelsQueryOptions, + useSavePanels, +} from "@/components/Dashboard/queries" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" + +/** The store only accepts this shape, so say so before the request does. */ +const slugify = (value: string) => + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + +/** + * Which dashboards hang on which screen, and adopting the screens themselves. + * + * A panel is a device rather than a document: it has no draft and nothing to + * publish, so it lives in a dialog over the dashboards list instead of a page + * of its own. Every change saves as it is made — there is no form to submit. + */ +export function PanelsDialog() { + const { data: config } = useQuery(panelsQueryOptions()) + const { data: dashboards } = useQuery(dashboardsQueryOptions()) + const save = useSavePanels() + const { showErrorToast } = useCustomToast() + const [name, setName] = useState("") + + const panels = config?.panels ?? [] + const known = dashboards?.data ?? [] + + const write = (next: PanelsConfig) => + save.mutate(next, { + onError: (error) => handleError.call(showErrorToast, error as ApiError), + }) + + const replace = (id: string, panel: PanelDef) => + write({ panels: panels.map((p) => (p.id === id ? panel : p)) }) + + const newId = slugify(name) + const taken = panels.some((panel) => panel.id === newId) + + return ( + + + Panels + + A panel is one screen and the dashboards it shows. Point the device at + the link, and it asks for a code you enter here. + + + +
+ {panels.length === 0 ? ( +

+ No panels yet. Add one below. +

+ ) : null} + + {panels.map((panel) => ( + ({ + name: dashboard.name, + title: dashboard.title || dashboard.name, + }))} + onChange={(next) => replace(panel.id, next)} + onRemove={() => + write({ panels: panels.filter((p) => p.id !== panel.id) }) + } + /> + ))} + + + +
{ + event.preventDefault() + if (!newId || taken) return + write({ panels: [...panels, { id: newId, title: name.trim() }] }) + setName("") + }} + > +
+ + setName(event.target.value)} + /> +
+ +
+ {taken ? ( +

+ There is already a panel called {newId}. +

+ ) : null} +
+
+ ) +} + +function PanelRow({ + panel, + dashboards, + onChange, + onRemove, +}: { + panel: PanelDef + dashboards: { name: string; title: string }[] + onChange: (next: PanelDef) => void + onRemove: () => void +}) { + const { showSuccessToast, showErrorToast } = useCustomToast() + const [code, setCode] = useState("") + const assigned = panel.dashboards ?? [] + + const pair = useMutation({ + mutationFn: () => + PanelsService.approvePairing({ + panelId: panel.id, + requestBody: { code: code.trim().toUpperCase() }, + }), + onSuccess: () => { + setCode("") + showSuccessToast( + "Paired — the screen switches over within a few seconds.", + ) + }, + onError: handleError.bind(showErrorToast), + }) + + const toggle = (dashboard: string) => + onChange({ + ...panel, + // ponytail: order follows the order they were ticked. Arrows if anyone + // asks for them. + dashboards: assigned.includes(dashboard) + ? assigned.filter((name) => name !== dashboard) + : [...assigned, dashboard], + }) + + const link = `${window.location.origin}/panel/${panel.id}` + + return ( +
+
+ + onChange({ ...panel, title: event.target.value }) + } + /> + + {panel.id} + + +
+ + {dashboards.length === 0 ? ( +

+ No dashboards to assign yet. +

+ ) : ( +
+ {dashboards.map((dashboard) => { + const position = assigned.indexOf(dashboard.name) + const id = `assign-${panel.id}-${dashboard.name}` + return ( + + ) + })} +
+ )} + +
+ event.currentTarget.select()} + /> +
{ + event.preventDefault() + if (code.trim()) pair.mutate() + }} + > + setCode(event.target.value.toUpperCase())} + /> + +
+
+
+ ) +} diff --git a/frontend/src/components/Dashboard/queries.ts b/frontend/src/components/Dashboard/queries.ts index 82950b3..2d412ee 100644 --- a/frontend/src/components/Dashboard/queries.ts +++ b/frontend/src/components/Dashboard/queries.ts @@ -4,6 +4,8 @@ import { type DashboardDef_Input, DashboardsService, MessagesService, + type PanelsConfig, + PanelsService, } from "@/client" export const dashboardKeys = { @@ -31,6 +33,41 @@ export const dashboardQueryOptions = (name: string, draft = false) => ({ queryFn: () => DashboardsService.readDashboard({ name, draft }), }) +export const panelKeys = { + all: ["panels"] as const, + detail: (id: string) => ["panels", id] as const, +} + +/** Every panel and what it shows. Read by the editor to know if a rail is due. */ +export const panelsQueryOptions = () => ({ + queryKey: panelKeys.all, + queryFn: () => PanelsService.readPanels(), +}) + +/** + * One panel, as the device hanging on the wall reads it. + * + * A panel credential may read exactly this and the dashboards it names, so + * this is the query the panel route is built on rather than the list above. + */ +export const panelQueryOptions = (id: string) => ({ + queryKey: panelKeys.detail(id), + queryFn: () => PanelsService.readPanel({ panelId: id }), +}) + +/** Replace the panels. Superuser-only on the server. */ +export function useSavePanels() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (body: PanelsConfig) => + PanelsService.savePanels({ requestBody: body }), + onSuccess: (saved) => { + queryClient.setQueryData(panelKeys.all, saved) + queryClient.invalidateQueries({ queryKey: panelKeys.all }) + }, + }) +} + /** Every message any flow declares — what a widget can be pointed at. */ export const messageCatalogQueryOptions = () => ({ queryKey: dashboardKeys.messages, diff --git a/frontend/src/components/Flow/CanvasTitle.tsx b/frontend/src/components/Flow/CanvasTitle.tsx index 31a4628..06322a1 100644 --- a/frontend/src/components/Flow/CanvasTitle.tsx +++ b/frontend/src/components/Flow/CanvasTitle.tsx @@ -11,6 +11,10 @@ import { slideUp, transitions } from "@/lib/motion" * what the overviews are for, so this bar carries the name and nothing that * takes you away from it. Shared by the flow editor and the dashboards so the * two shells read as one. + * + * The one exception sits beside it rather than in it: a dashboard assigned to + * a panel alongside others is edited with that panel's rail on screen, because + * the wall has it too and it takes room off the canvas. */ export function CanvasTitle({ children }: { children: ReactNode }) { return ( diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3e0618c..db91d01 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -39,6 +39,17 @@ const handleApiError = (error: Error) => { return } if (isAuthFailure(error)) { + // A paired wall panel has no login screen to go back to — it asks for a + // new code instead. Only a 401 is worth throwing its credential away for: + // a 403 there is a dashboard it was just unassigned from, which the next + // read of the panel corrects on its own. + if (window.location.pathname.startsWith("/panel")) { + if (error instanceof ApiError && error.status === 401) { + localStorage.removeItem("access_token") + window.location.href = "/panel" + } + return + } if (portal) { // The portal knows whether they are still signed in; it can mint a fresh // handoff or send them to the login screen. diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 478a5b3..5f3b0e3 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -15,8 +15,10 @@ import { Route as RecoverPasswordRouteImport } from './routes/recover-password' import { Route as LoginRouteImport } from './routes/login' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as CanvasRouteImport } from './routes/_canvas' +import { Route as PanelIndexRouteImport } from './routes/panel.index' import { Route as LayoutIndexRouteImport } from './routes/_layout/index' import { Route as ViewNameRouteImport } from './routes/view.$name' +import { Route as PanelIdRouteImport } from './routes/panel.$id' import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize' import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutSecretsRouteImport } from './routes/_layout/secrets' @@ -56,6 +58,11 @@ const CanvasRoute = CanvasRouteImport.update({ id: '/_canvas', getParentRoute: () => rootRouteImport, } as any) +const PanelIndexRoute = PanelIndexRouteImport.update({ + id: '/panel/', + path: '/panel/', + getParentRoute: () => rootRouteImport, +} as any) const LayoutIndexRoute = LayoutIndexRouteImport.update({ id: '/', path: '/', @@ -66,6 +73,11 @@ const ViewNameRoute = ViewNameRouteImport.update({ path: '/view/$name', getParentRoute: () => rootRouteImport, } as any) +const PanelIdRoute = PanelIdRouteImport.update({ + id: '/panel/$id', + path: '/panel/$id', + getParentRoute: () => rootRouteImport, +} as any) const OauthAuthorizeRoute = OauthAuthorizeRouteImport.update({ id: '/oauth/authorize', path: '/oauth/authorize', @@ -129,7 +141,9 @@ export interface FileRoutesByFullPath { '/secrets': typeof LayoutSecretsRoute '/settings': typeof LayoutSettingsRoute '/oauth/authorize': typeof OauthAuthorizeRoute + '/panel/$id': typeof PanelIdRoute '/view/$name': typeof ViewNameRoute + '/panel/': typeof PanelIndexRoute '/dashboards/$name': typeof CanvasDashboardsNameRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute '/dashboards/': typeof LayoutDashboardsIndexRoute @@ -147,7 +161,9 @@ export interface FileRoutesByTo { '/secrets': typeof LayoutSecretsRoute '/settings': typeof LayoutSettingsRoute '/oauth/authorize': typeof OauthAuthorizeRoute + '/panel/$id': typeof PanelIdRoute '/view/$name': typeof ViewNameRoute + '/panel': typeof PanelIndexRoute '/dashboards/$name': typeof CanvasDashboardsNameRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute '/dashboards': typeof LayoutDashboardsIndexRoute @@ -167,8 +183,10 @@ export interface FileRoutesById { '/_layout/secrets': typeof LayoutSecretsRoute '/_layout/settings': typeof LayoutSettingsRoute '/oauth/authorize': typeof OauthAuthorizeRoute + '/panel/$id': typeof PanelIdRoute '/view/$name': typeof ViewNameRoute '/_layout/': typeof LayoutIndexRoute + '/panel/': typeof PanelIndexRoute '/_canvas/dashboards/$name': typeof CanvasDashboardsNameRoute '/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute '/_layout/dashboards/': typeof LayoutDashboardsIndexRoute @@ -188,7 +206,9 @@ export interface FileRouteTypes { | '/secrets' | '/settings' | '/oauth/authorize' + | '/panel/$id' | '/view/$name' + | '/panel/' | '/dashboards/$name' | '/flows/$flowName' | '/dashboards/' @@ -206,7 +226,9 @@ export interface FileRouteTypes { | '/secrets' | '/settings' | '/oauth/authorize' + | '/panel/$id' | '/view/$name' + | '/panel' | '/dashboards/$name' | '/flows/$flowName' | '/dashboards' @@ -225,8 +247,10 @@ export interface FileRouteTypes { | '/_layout/secrets' | '/_layout/settings' | '/oauth/authorize' + | '/panel/$id' | '/view/$name' | '/_layout/' + | '/panel/' | '/_canvas/dashboards/$name' | '/_canvas/flows/$flowName' | '/_layout/dashboards/' @@ -241,7 +265,9 @@ export interface RootRouteChildren { ResetPasswordRoute: typeof ResetPasswordRoute SignupRoute: typeof SignupRoute OauthAuthorizeRoute: typeof OauthAuthorizeRoute + PanelIdRoute: typeof PanelIdRoute ViewNameRoute: typeof ViewNameRoute + PanelIndexRoute: typeof PanelIndexRoute } declare module '@tanstack/react-router' { @@ -288,6 +314,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CanvasRouteImport parentRoute: typeof rootRouteImport } + '/panel/': { + id: '/panel/' + path: '/panel' + fullPath: '/panel/' + preLoaderRoute: typeof PanelIndexRouteImport + parentRoute: typeof rootRouteImport + } '/_layout/': { id: '/_layout/' path: '/' @@ -302,6 +335,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ViewNameRouteImport parentRoute: typeof rootRouteImport } + '/panel/$id': { + id: '/panel/$id' + path: '/panel/$id' + fullPath: '/panel/$id' + preLoaderRoute: typeof PanelIdRouteImport + parentRoute: typeof rootRouteImport + } '/oauth/authorize': { id: '/oauth/authorize' path: '/oauth/authorize' @@ -421,7 +461,9 @@ const rootRouteChildren: RootRouteChildren = { ResetPasswordRoute: ResetPasswordRoute, SignupRoute: SignupRoute, OauthAuthorizeRoute: OauthAuthorizeRoute, + PanelIdRoute: PanelIdRoute, ViewNameRoute: ViewNameRoute, + PanelIndexRoute: PanelIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/frontend/src/routes/_layout/dashboards/index.tsx b/frontend/src/routes/_layout/dashboards/index.tsx index ff3db47..583fcaf 100644 --- a/frontend/src/routes/_layout/dashboards/index.tsx +++ b/frontend/src/routes/_layout/dashboards/index.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" -import { LayoutDashboard } from "lucide-react" +import { LayoutDashboard, MonitorSmartphone } from "lucide-react" import { useState } from "react" import { DashboardsService } from "@/client" @@ -8,6 +8,7 @@ import { OverviewToolbar, usePublishAll, } from "@/components/Common/OverviewToolbar" +import { PanelsDialog } from "@/components/Dashboard/PanelsDialog" import { dashboardKeys, dashboardsQueryOptions, @@ -22,6 +23,11 @@ import { DialogTitle, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" import useCustomToast from "@/hooks/useCustomToast" import { handleError } from "@/utils" @@ -37,6 +43,7 @@ function Dashboards() { const [name, setName] = useState("") const [search, setSearch] = useState("") const [dialogOpen, setDialogOpen] = useState(false) + const [panelsOpen, setPanelsOpen] = useState(false) const create = useMutation({ mutationFn: (dashboard: string) => @@ -92,6 +99,12 @@ function Dashboards() {

+ {/* Its own root rather than a nested one: which screens exist is a + different question from which dashboards do. */} + + + + publishAll.mutate(drafts)} - /> + > + + + + + Panels + +
): { d?: string } => + typeof search.d === "string" ? { d: search.d } : {}, + beforeLoad: async () => { + // Unpaired, or a credential the server stopped honouring. Either way this + // device needs a new code, not a login form it cannot type into. + if (!isLoggedIn()) { + throw redirect({ to: "/panel" }) + } + }, + head: ({ params }) => ({ meta: [{ title: `${params.id} - Fluksio` }] }), +}) + +function PanelRoute() { + const { id } = Route.useParams() + const { d } = Route.useSearch() + useFlowSocket() + + const { data: panel } = useQuery(panelQueryOptions(id)) + const dashboards = panel?.dashboards ?? [] + // A name in the URL that this panel no longer carries falls back to the + // first, which is what a dashboard removed from under a running panel does. + const current = d && dashboards.includes(d) ? d : dashboards[0] + + const { data: dashboard } = useQuery({ + ...dashboardQueryOptions(current ?? ""), + enabled: Boolean(current), + }) + const stacked = useIsMobile() + const rail = dashboards.length > 1 + + if (panel && dashboards.length === 0) { + return ( +
+

+ No dashboards are assigned to this panel yet. +

+
+ ) + } + + return ( +
+ {rail && current ? ( + ({ + to: "/panel/$id", + params: { id }, + search: { d: name }, + })} + /> + ) : null} + {dashboard ? ( + + ) : null} +
+ ) +} diff --git a/frontend/src/routes/panel.index.tsx b/frontend/src/routes/panel.index.tsx new file mode 100644 index 0000000..89a0414 --- /dev/null +++ b/frontend/src/routes/panel.index.tsx @@ -0,0 +1,92 @@ +import { useMutation, useQuery } from "@tanstack/react-query" +import { createFileRoute } from "@tanstack/react-router" +import { useEffect } from "react" + +import { PanelsService } from "@/client" + +/** + * Adopting a screen that has no keyboard. + * + * A wall tablet cannot be asked to type an email address and a password, so it + * asks for a code instead and shows it. Somebody with an account types that + * code into the panels dialog to say which panel this device is, and the + * credential it mints arrives here on the next poll. + * + * Outside both shells like `/view/{name}`: until it is paired, this device has + * no session and there is nothing to put around it. + */ +export const Route = createFileRoute("/panel/")({ + component: PairPanel, + head: () => ({ meta: [{ title: "Pair this panel - Fluksio" }] }), +}) + +function PairPanel() { + // A code lives ten minutes. Asking for one is the mount, and asking again is + // what happens when this one is no longer recognised. + const start = useMutation({ mutationFn: () => PanelsService.startPairing() }) + const request = start.mutate + + // biome-ignore lint/correctness/useExhaustiveDependencies: asked for once, when the screen goes up. + useEffect(() => { + request() + }, []) + + const started = start.data + const { data: status, error } = useQuery({ + queryKey: ["pairing", started?.code], + queryFn: () => + PanelsService.pollPairing({ + code: started?.code ?? "", + secret: started?.secret ?? "", + }), + enabled: Boolean(started), + refetchInterval: 3000, + // A 404 means this code is finished, not that the request failed. + retry: false, + }) + + // Expired, or collected already. Ask for another rather than leaving a + // number on the wall that no longer works. + useEffect(() => { + if (error) request() + }, [error, request]) + + useEffect(() => { + if (!status?.access_token || !status.panel) return + localStorage.setItem("access_token", status.access_token) + // A full load rather than a route change: everything this page asked for + // was asked without a credential, and the socket has to dial again holding + // this one. + window.location.href = `/panel/${status.panel}` + }, [status]) + + return ( +
+
+
+

Pair this panel

+

+ On a computer, open Dashboards{" "} + → Panels, pick which panel this + is, and enter this code. +

+
+ +

+ + {/* Spelled out, so a reader says the characters rather than trying + to pronounce them as a word. */} + + {(started?.code ?? "").split("").join(" ")} + +

+ +

+ The code lasts ten minutes; this screen replaces it when it runs out. +

+
+
+ ) +} diff --git a/frontend/src/routes/view.$name.tsx b/frontend/src/routes/view.$name.tsx index a6c1dbe..c57b077 100644 --- a/frontend/src/routes/view.$name.tsx +++ b/frontend/src/routes/view.$name.tsx @@ -2,21 +2,22 @@ import { useQuery } from "@tanstack/react-query" import { createFileRoute, redirect } from "@tanstack/react-router" import type { Dashboard } from "@/components/Dashboard/DashboardView" -import { - CanvasSurface, - DashboardView, -} from "@/components/Dashboard/DashboardView" +import { PanelSurface } from "@/components/Dashboard/PanelSurface" import { dashboardQueryOptions } from "@/components/Dashboard/queries" import { useFlowSocket } from "@/components/Flow/useFlowSocket" import { isLoggedIn } from "@/hooks/useAuth" import { useIsMobile } from "@/hooks/useMobile" /** - * What a wall panel is pointed at. + * What a wall panel is pointed at when it shows one dashboard and nothing else. * * Deliberately outside both shells: no sidebar, no footer, no editing, and no * column cap — the widgets are the whole page. Route splitting means a panel * never downloads the editor or the grid library either. + * + * A device that was paired to a panel uses `/panel/{id}` instead, which is the + * same surface with a rail for switching between the dashboards it was + * assigned. This route stays for a browser tab pointed straight at one. */ export const Route = createFileRoute("/view/$name")({ component: PanelView, @@ -32,27 +33,19 @@ function PanelView() { const { name } = Route.useParams() useFlowSocket() const { data: dashboard } = useQuery(dashboardQueryOptions(name)) - // A landscape arrangement scaled onto a phone comes out at about a fifth of - // its size, which reads as nothing at all. Stack it instead. const stacked = useIsMobile() if (!dashboard) return
- if (stacked) { - return ( -
- -
- ) - } - return ( -
- {/* The panel's own surface, scaled to whatever screen it landed on. No - dots: nothing is being arranged here. */} - - {() => } - +
+
) }