Panels: per-device dashboard sets, paired by code

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 7900eaebaa
commit ffae24c16b
26 changed files with 1696 additions and 40 deletions
+9 -8
View File
@@ -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.
+10 -1
View File
@@ -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
+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
+104
View File
@@ -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: {
+136 -1
View File
@@ -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<PanelsReadPanelsResponse> {
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<PanelsSavePanelsResponse> {
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<PanelsStartPairingResponse> {
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<PanelsPollPairingResponse> {
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<PanelsApprovePairingResponse> {
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<PanelsReadPanelResponse> {
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
+67
View File
@@ -707,6 +707,43 @@ export type PageDef_Output = {
sections?: Array<SectionDef_Output>;
};
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<PanelDef>;
};
/**
* Where a widget sits in its section's grid, in grid units.
*/
@@ -1356,6 +1393,36 @@ export type ObservabilityReadDeadLettersData = {
export type ObservabilityReadDeadLettersResponse = (Array<DeadLetter>);
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;
};
@@ -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<HTMLButtonElement>(null)
@@ -62,6 +65,7 @@ export function OverviewToolbar({
return (
<div className="flex items-center justify-end gap-1">
{children}
{open ? (
<motion.div
initial={{ width: 0, opacity: 0 }}
@@ -1,4 +1,4 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import {
Check,
@@ -68,9 +68,11 @@ import {
sectionsOf,
widgetsOf,
} from "./DashboardView"
import { PanelRail, RAIL_INSET } from "./PanelRail"
import { DashboardPanel, WidgetPanel } from "./panels"
import {
dashboardKeys,
panelsQueryOptions,
useDiscardDashboardDraft,
usePublishDashboard,
useSaveDashboard,
@@ -351,6 +353,18 @@ export function DashboardEditor({
const active = widgets.find((widget) => 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 ? (
<PanelRail
dashboards={railDashboards}
current={draft.name}
// Editing one dashboard of a panel and editing its neighbour are the
// same job, so the rail keeps whichever mode this one is in.
linkFor={(name) => ({
to: "/dashboards/$name",
params: { name },
search: edit ? { edit: true } : {},
})}
/>
) : null}
<div
className={cn(
"absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
panelOpen && "md:pr-[27rem]",
)}
style={
railDashboards && !stacked ? { paddingLeft: RAIL_INSET } : undefined
}
data-testid="dashboard-canvas"
>
{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}
>
<CanvasTitle>
<span className="truncate px-3 py-1.5 text-sm font-medium">
@@ -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 (
<nav
aria-label="Dashboards on this panel"
data-testid="panel-rail"
className={cn(
"pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-12 flex-col items-center gap-1 overflow-y-auto rounded-lg border border-border bg-card/80 p-1 shadow-e2 backdrop-blur-md",
className,
)}
>
{dashboards.map((name, index) => {
const label = titles[index]
const active = name === current
return (
<Tooltip key={name}>
<TooltipTrigger asChild>
<Button
asChild
variant="ghost"
// `icon-lg` at every width on purpose: a rail is a touch
// surface whatever the screen's size, and the panel it hangs
// on is 1024 wide as often as it is 400.
size="icon-lg"
className={cn(
"shrink-0 text-xs font-medium text-muted-foreground",
active && "bg-accent text-foreground",
)}
aria-current={active ? "page" : undefined}
data-testid={`panel-rail-${name}`}
>
<Link {...linkFor(name)} aria-label={label}>
{initials(label)}
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
)
})}
</nav>
)
}
@@ -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 <DashboardView dashboard={dashboard} stacked />
// The panel's own surface, scaled to fit. No dots: nothing is being
// arranged here.
return (
<CanvasSurface dashboard={dashboard}>
{() => <DashboardView dashboard={dashboard} />}
</CanvasSurface>
)
}
@@ -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 (
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Panels</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="grid gap-6">
{panels.length === 0 ? (
<p className="text-sm text-muted-foreground">
No panels yet. Add one below.
</p>
) : null}
{panels.map((panel) => (
<PanelRow
key={panel.id}
panel={panel}
dashboards={known.map((dashboard) => ({
name: dashboard.name,
title: dashboard.title || dashboard.name,
}))}
onChange={(next) => replace(panel.id, next)}
onRemove={() =>
write({ panels: panels.filter((p) => p.id !== panel.id) })
}
/>
))}
<Separator />
<form
className="flex items-end gap-2"
onSubmit={(event) => {
event.preventDefault()
if (!newId || taken) return
write({ panels: [...panels, { id: newId, title: name.trim() }] })
setName("")
}}
>
<div className="grid flex-1 gap-1">
<label className="text-sm" htmlFor="new-panel">
New panel
</label>
<Input
id="new-panel"
value={name}
placeholder="hallway"
autoComplete="off"
data-testid="new-panel-name"
onChange={(event) => setName(event.target.value)}
/>
</div>
<Button
type="submit"
disabled={!newId || taken}
data-testid="add-panel"
>
Add panel
</Button>
</form>
{taken ? (
<p className="text-sm text-destructive">
There is already a panel called {newId}.
</p>
) : null}
</div>
</DialogContent>
)
}
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 (
<div className="grid gap-3" data-testid={`panel-${panel.id}`}>
<div className="flex items-center gap-2">
<Input
value={panel.title}
placeholder={panel.id}
aria-label={`Title of ${panel.id}`}
onChange={(event) =>
onChange({ ...panel, title: event.target.value })
}
/>
<span className="shrink-0 text-sm text-muted-foreground">
{panel.id}
</span>
<Button
variant="ghost"
size="icon"
className="size-11 shrink-0 text-muted-foreground md:size-8"
aria-label={`Remove ${panel.id}`}
data-testid={`remove-panel-${panel.id}`}
onClick={onRemove}
>
<Trash2 />
</Button>
</div>
{dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
No dashboards to assign yet.
</p>
) : (
<div className="grid gap-2">
{dashboards.map((dashboard) => {
const position = assigned.indexOf(dashboard.name)
const id = `assign-${panel.id}-${dashboard.name}`
return (
<label
key={dashboard.name}
htmlFor={id}
className="flex items-center gap-2 text-sm"
>
<Checkbox
id={id}
checked={position >= 0}
data-testid={id}
onCheckedChange={() => toggle(dashboard.name)}
/>
<span className="flex-1 truncate">{dashboard.title}</span>
{position >= 0 ? (
<span className="text-xs text-muted-foreground">
{position + 1}
</span>
) : null}
</label>
)
})}
</div>
)}
<div className="grid gap-2">
<Input
readOnly
value={link}
aria-label={`Link for ${panel.id}`}
className="text-muted-foreground"
onFocus={(event) => event.currentTarget.select()}
/>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault()
if (code.trim()) pair.mutate()
}}
>
<Input
value={code}
placeholder="Code shown on the screen"
aria-label={`Pairing code for ${panel.id}`}
autoComplete="off"
maxLength={6}
data-testid={`pair-code-${panel.id}`}
onChange={(event) => setCode(event.target.value.toUpperCase())}
/>
<Button
type="submit"
variant="outline"
disabled={!code.trim() || pair.isPending}
data-testid={`pair-${panel.id}`}
>
Pair device
</Button>
</form>
</div>
</div>
)
}
@@ -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,
@@ -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 (
+11
View File
@@ -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.
+42
View File
@@ -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)
@@ -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() {
</p>
</div>
{/* Its own root rather than a nested one: which screens exist is a
different question from which dashboards do. */}
<Dialog open={panelsOpen} onOpenChange={setPanelsOpen}>
<PanelsDialog />
</Dialog>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<OverviewToolbar
search={search}
@@ -103,7 +116,23 @@ function Dashboards() {
draftCount={drafts.length}
publishing={publishAll.isPending}
onPublishAll={() => publishAll.mutate(drafts)}
/>
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
aria-label="Panels"
data-testid="open-panels"
onClick={() => setPanelsOpen(true)}
>
<MonitorSmartphone />
</Button>
</TooltipTrigger>
<TooltipContent>Panels</TooltipContent>
</Tooltip>
</OverviewToolbar>
<DialogContent>
<form
className="grid gap-4"
+93
View File
@@ -0,0 +1,93 @@
import { useQuery } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { PanelRail, RAIL_INSET } from "@/components/Dashboard/PanelRail"
import { PanelSurface } from "@/components/Dashboard/PanelSurface"
import {
dashboardQueryOptions,
panelQueryOptions,
} from "@/components/Dashboard/queries"
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
import { isLoggedIn } from "@/hooks/useAuth"
import { useIsMobile } from "@/hooks/useMobile"
import { cn } from "@/lib/utils"
/**
* What a paired device shows: the dashboards this panel was assigned.
*
* The same full-bleed surface as `/view/{name}`, with a rail down the left
* when there is more than one to switch between — so a hallway tablet and a
* workshop tablet can carry different sets without either dashboard knowing
* anything about the other.
*
* Which one is open lives in the URL, so a panel that reboots comes back where
* it was rather than at the first one.
*/
export const Route = createFileRoute("/panel/$id")({
component: PanelRoute,
validateSearch: (search: Record<string, unknown>): { 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 (
<main className="grid h-svh w-full place-items-center p-8">
<p className="text-sm text-muted-foreground">
No dashboards are assigned to this panel yet.
</p>
</main>
)
}
return (
<main
className={cn(
"relative h-svh w-full p-4",
stacked ? "overflow-y-auto" : "overflow-hidden",
)}
style={rail ? { paddingLeft: RAIL_INSET } : undefined}
>
{rail && current ? (
<PanelRail
dashboards={dashboards}
current={current}
linkFor={(name) => ({
to: "/panel/$id",
params: { id },
search: { d: name },
})}
/>
) : null}
{dashboard ? (
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} />
) : null}
</main>
)
}
+92
View File
@@ -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 (
<main className="grid h-svh w-full place-items-center p-8">
<div className="grid max-w-md gap-6 text-center">
<div className="grid gap-2">
<h1 className="text-2xl">Pair this panel</h1>
<p className="text-sm text-muted-foreground">
On a computer, open <span className="font-medium">Dashboards</span>{" "}
<span className="font-medium">Panels</span>, pick which panel this
is, and enter this code.
</p>
</div>
<p className="font-mono text-6xl font-medium tracking-[0.2em]">
<span data-testid="pairing-code" aria-hidden="true">
{started?.code ?? "······"}
</span>
{/* Spelled out, so a reader says the characters rather than trying
to pronounce them as a word. */}
<span className="sr-only">
{(started?.code ?? "").split("").join(" ")}
</span>
</p>
<p className="text-sm text-muted-foreground">
The code lasts ten minutes; this screen replaces it when it runs out.
</p>
</div>
</main>
)
}
+13 -20
View File
@@ -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 <main className="h-svh w-full" />
if (stacked) {
return (
<main className="h-svh w-full overflow-y-auto p-4">
<DashboardView dashboard={dashboard as Dashboard} stacked />
</main>
)
<main
className={
stacked
? "h-svh w-full overflow-y-auto p-4"
: "h-svh w-full overflow-hidden p-4"
}
return (
<main className="h-svh w-full overflow-hidden p-4">
{/* The panel's own surface, scaled to whatever screen it landed on. No
dots: nothing is being arranged here. */}
<CanvasSurface dashboard={dashboard as Dashboard}>
{() => <DashboardView dashboard={dashboard as Dashboard} />}
</CanvasSurface>
>
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} />
</main>
)
}