diff --git a/backend/fluksio/api/deps.py b/backend/fluksio/api/deps.py index d3dac28..f549043 100644 --- a/backend/fluksio/api/deps.py +++ b/backend/fluksio/api/deps.py @@ -15,10 +15,11 @@ from fluksio.cloud import enroll from fluksio.core import security from fluksio.core.config import settings from fluksio.core.db import engine -from fluksio.flow import panels +from fluksio.flow import devices from fluksio.flow.artifacts import is_reference from fluksio.flow.controller import FlowController from fluksio.flow.dashboards import DashboardDef, DashboardStore +from fluksio.flow.devices import DeviceDef, DeviceStore, presence from fluksio.flow.workers import PythonWorkerPool from fluksio.models import OAuthClient, TokenPayload, User, WorkerToken @@ -40,35 +41,46 @@ TokenDep = Annotated[str, Depends(reusable_oauth2)] #: literals; anything else is refused rather than guessed at. _NOT_DRAFT = {"false", "0", "off", "f", "n", "no", ""} -#: The one route under a message name a panel reads. +#: The one route under a message name a device reads. _HISTORY = "/history" -def _panel_for(payload: dict[str, Any]) -> panels.PanelDef: - """The panel a credential names, if that credential still stands. +def _device_for(payload: dict[str, Any], app: Any) -> DeviceDef: + """The device a credential names, if that credential still stands. - Two ways it stops standing. The panel was deleted, which revokes every - credential ever minted for it — read from disk on each call, so it takes - effect at once. Or the panel's nonce moved on, which revokes exactly one - screen's and leaves the panel, its dashboards and their arrangement alone. + Two ways it stops standing. The device was deleted, which revokes every + credential ever minted for it, read from disk on each call so it takes + effect at once. Or the device's nonce moved on, which revokes exactly one + screen's and leaves the device and its dashboards alone. - The nonce is only held against a credential this instance signed. One - the portal minted for a remote screen carries none — the portal names the - panel and nothing else — and is revoked at the hub instead. + The nonce is only held against a credential this instance signed. One the + portal minted for a remote screen carries none, the portal names the + device and nothing else, and is revoked at the hub instead. + + The claim is still called ``panel``: tokens in circulation say it, and + nothing pairs again for a rename. """ - panel = panels.find(str(payload.get("panel", ""))) - if panel is None: - raise InvalidTokenError("This panel no longer exists") - if ( - payload.get("aud") == security.PANEL_AUDIENCE - and int(payload.get("pnc") or 0) != panel.nonce - ): - raise InvalidTokenError("This panel was paired with another device") - return panel + store: DeviceStore | None = ( + getattr(app.state, "device_store", None) if app else None + ) + if store is None: + raise InvalidTokenError("The device store is not running") + device = store.find(str(payload.get("panel", ""))) + if device is None: + raise InvalidTokenError("This device no longer exists") + if payload.get("aud") == security.PANEL_AUDIENCE: + try: + nonce = store.nonce(device.id) + except (OSError, ValueError) as exc: + raise InvalidTokenError(f"The device nonces are unreadable: {exc}") + if int(payload.get("pnc") or 0) != nonce: + raise InvalidTokenError("This device was paired again") + presence.seen(device.id) + return device -def _panel_messages(panel_id: str, request: Request) -> set[str]: - """Every message this panel's widgets read or write. +def _device_messages(device: DeviceDef, request: Request) -> set[str]: + """Every message this device's widgets and ports read or write. The same walk that bounds its socket, so the two surfaces a screen has agree on what it is entitled to. No store means nothing resolves, which is @@ -77,7 +89,7 @@ def _panel_messages(panel_id: str, request: Request) -> set[str]: store: DashboardStore | None = getattr(request.app.state, "dashboard_store", None) if store is None: return set() - return panels.messages_for(panel_id, store) + return devices.messages_for(device, store) def _locked(defn: DashboardDef, live: dict[str, Any]) -> bool: @@ -96,8 +108,8 @@ def _locked(defn: DashboardDef, live: dict[str, Any]) -> bool: return (setting.value if value is None else value) is True -def _panel_writable(panel_id: str, request: Request) -> set[str]: - """The messages this panel may publish to. +def _device_writable(device: DeviceDef, request: Request) -> set[str]: + """The messages this device may publish to. What its dashboards' inputs target, which is narrower than what it may read: a chart, a reading or a bound setting is something a screen shows, @@ -109,7 +121,7 @@ def _panel_writable(panel_id: str, request: Request) -> set[str]: that asked anyway. Not nothing, though: a querying chart publishes the request it reads by, and a locked dashboard whose charts cannot ask goes blank rather than read-only. That request is the exception - ``panels.requests_of`` names. + ``devices.requests_of`` names. Union, exactly as the allowlist itself is: a message one dashboard on this panel displays and another controls stays writable, because the unlocked @@ -123,8 +135,8 @@ def _panel_writable(panel_id: str, request: Request) -> set[str]: store: DashboardStore | None = getattr(request.app.state, "dashboard_store", None) if store is None: return set() - writable = panels.io_in(panel_id) - defns = panels.dashboards_for(panel_id, store) + writable = devices.io_in(device) + defns = devices.dashboards_for(device, store) bound = sorted( {s.message for d in defns if (s := d.settings.get("locked")) and s.message} ) @@ -134,12 +146,14 @@ def _panel_writable(panel_id: str, request: Request) -> set[str]: live = controller.state.get_present(bound) if bound and controller else {} for defn in defns: writable |= ( - panels.requests_of(defn) if _locked(defn, live) else panels.targets_of(defn) + devices.requests_of(defn) + if _locked(defn, live) + else devices.targets_of(defn) ) return writable -def _panel_digests(panel_id: str, request: Request) -> set[str]: +def _device_digests(device: DeviceDef, request: Request) -> set[str]: """The artifacts this panel's messages are pointing at right now. A media tile fetches the bytes its message names, so the messages already @@ -151,7 +165,7 @@ def _panel_digests(panel_id: str, request: Request) -> set[str]: ) if controller is None: return set() - names = _panel_messages(panel_id, request) + names = _device_messages(device, request) if not names: return set() found: set[str] = set() @@ -161,7 +175,7 @@ def _panel_digests(panel_id: str, request: Request) -> set[str]: return found -def _panel_may(payload: dict[str, Any], request: Request) -> None: +def _device_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 @@ -183,11 +197,11 @@ def _panel_may(payload: dict[str, Any], request: Request) -> None: a panel to read every message it names and to publish to none of them but the requests its own charts ask by. - # ponytail: the panel file and the published dashboards are re-read per - # request. Cache them behind the store's version if this shows up in a - # profile. + # ponytail: the device file, its nonce and the published dashboards are + # re-read per request. Cache them behind the store's revision if this + # shows up in a profile. """ - panel = _panel_for(payload) + device = _device_for(payload, request.app) api = settings.API_V1_STR path = request.url.path @@ -203,8 +217,12 @@ def _panel_may(payload: dict[str, Any], request: Request) -> None: # 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 = "/" not in name and name in device.dashboards and published + elif method == "GET" and path in ( + f"{api}/devices/{device.id}", + # What firmware from before devices reads. + f"{api}/panels/{device.id}", + ): allowed = True elif method == "POST" and path == f"{api}/flows/ws-ticket": # How its live socket opens; the socket applies the panel's own scope. @@ -214,36 +232,38 @@ def _panel_may(payload: dict[str, Any], request: Request) -> None: # value in. Percent-decoded, because a message name is a path segment # here and the client encodes it as one. rest = unquote(path[len(f"{api}/messages/") :]) - scope = _panel_messages + scope = _device_messages if method == "GET" and rest.endswith(_HISTORY): name = rest[: -len(_HISTORY)] elif method == "POST": name = rest # A locked dashboard entitles the panel to nothing it can publish. - scope = _panel_writable + scope = _device_writable else: name = "" - allowed = bool(name) and name in scope(panel.id, request) + allowed = bool(name) and name in scope(device, request) elif method == "GET" and path.startswith(f"{api}/artifacts/"): # The bytes behind a media message a tile on this panel is drawing. # Scoped to what those messages hold *now*, which is exactly what a # live widget asks for — a screen has no business reading an artifact # off an old run because it happens to know the digest. digest = unquote(path[len(f"{api}/artifacts/") :]) - allowed = "/" not in digest and digest in _panel_digests(panel.id, request) + allowed = "/" not in digest and digest in _device_digests(device, request) elif method == "PUT" and path == f"{api}/artifacts": # What the device's own sensor recorded — a microphone's utterance — # uploaded under the ``in`` message it is about to publish it to. - allowed = request.query_params.get("name", "") in panels.io_in(panel.id) + allowed = request.query_params.get("name", "") in devices.io_in(device) if not allowed: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="A panel credential cannot do that", + detail="A device credential cannot do that", ) -def decode_token(token: str, request: Request | None = None) -> dict[str, Any]: +def decode_token( + token: str, request: Request | None = None, app: Any = 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 @@ -254,8 +274,9 @@ def decode_token(token: str, request: Request | None = None) -> dict[str, Any]: 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. + refuses it outright, so the only door it fits is the one ``_device_may`` + guards. Where there is no request to scope (a socket handshake, the + tunnel), ``app`` is what a device credential is checked against. An agent's token is held to one thing more: the client it names has to still be registered, which is what makes revoking an agent take effect now @@ -286,11 +307,11 @@ def decode_token(token: str, request: Request | None = None) -> dict[str, Any]: else: if not panel_token.get("panel"): raise InvalidTokenError("a panel token must name its panel") - return _gate_panel(panel_token, request) + return _gate_panel(panel_token, request, app) try: agent = security.decode_oauth_token(token) except InvalidTokenError: - return _gate_panel(cloud_config.decode_portal_token(token), request) + return _gate_panel(cloud_config.decode_portal_token(token), request, app) return _gate_agent(agent) @@ -304,7 +325,7 @@ def oauth_client_lives(client_id: str) -> bool: One primary-key read per agent request, against a table with a row per registered agent. The request it gates goes on to look its user up the - same way, and the panel gate next door re-reads the panels file and every + same way, and the device gate next door re-reads the device's file and every published dashboard from disk, so this is the cheapest check in here. """ try: @@ -341,19 +362,22 @@ def _gate_agent(payload: dict[str, Any]) -> dict[str, Any]: return payload -def _gate_panel(payload: dict[str, Any], request: Request | None) -> dict[str, Any]: - """Scope a payload that names a panel; pass anything else through. +def _gate_panel( + payload: dict[str, Any], request: Request | None, app: Any = None +) -> dict[str, Any]: + """Scope a payload that names a device; pass anything else through. - Without a request — from the websocket, which has no route to scope — the - check is only that the credential still stands, which is what makes - deleting a panel, or bumping its nonce, revoke one. + Without a request (the websocket, which has no route to scope) the check + is only that the credential still stands, which is what makes deleting a + device, or bumping its nonce, revoke one. Without an app either there is + nothing to check it against, and it is refused. """ if not payload.get("panel"): return payload if request is not None: - _panel_may(payload, request) + _device_may(payload, request) else: - _panel_for(payload) + _device_for(payload, app) return payload @@ -393,17 +417,18 @@ def _user_for(session: Session, token_data: TokenPayload) -> User | None: def user_from_token( - session: Session, token: str, request: Request | None = None + session: Session, token: str, request: Request | None = None, app: Any = None ) -> User | None: """Resolve a bearer token to its user, or None if it does not hold up. Shared with the websocket, which cannot use the HTTP security scheme, and with the artifact endpoint, which accepts a worker's credential as well as - a person's. Pass the request wherever there is one: a panel's credential is - scoped by route, and without it the scope check cannot run. + a person's. Pass the request wherever there is one: a device's credential is + scoped by route, and without it the scope check cannot run. Without one, + pass the app, or a device credential is refused. """ try: - token_data = TokenPayload(**decode_token(token, request)) + token_data = TokenPayload(**decode_token(token, request, app)) except (InvalidTokenError, ValidationError): return None user = _user_for(session, token_data) @@ -462,6 +487,16 @@ def get_dashboard_store(request: Request) -> DashboardStore: DashboardStoreDep = Annotated[DashboardStore, Depends(get_dashboard_store)] +def get_device_store(request: Request) -> DeviceStore: + store: DeviceStore | None = getattr(request.app.state, "device_store", None) + if store is None: + raise HTTPException(status_code=503, detail="The flow engine is not running") + return store + + +DeviceStoreDep = Annotated[DeviceStore, Depends(get_device_store)] + + def get_worker_pool(request: Request) -> PythonWorkerPool: pool: PythonWorkerPool | None = getattr(request.app.state, "worker_pool", None) if pool is None: diff --git a/backend/fluksio/api/main.py b/backend/fluksio/api/main.py index 36c2130..197b02f 100644 --- a/backend/fluksio/api/main.py +++ b/backend/fluksio/api/main.py @@ -7,6 +7,7 @@ from fluksio.api.routes import ( cloud, connectors, dashboards, + devices, flavors, flows, login, @@ -14,7 +15,6 @@ from fluksio.api.routes import ( modules, oauth, observability, - panels, private, runs, search, @@ -35,7 +35,9 @@ api_router.include_router(secrets.router) api_router.include_router(connectors.router) api_router.include_router(alerts.router) api_router.include_router(dashboards.router) -api_router.include_router(panels.router) +api_router.include_router(devices.router) +# What the openpanel firmware calls today, on the same handlers. +api_router.include_router(devices.panels_router) api_router.include_router(messages.router) api_router.include_router(modules.router) api_router.include_router(observability.router) diff --git a/backend/fluksio/api/routes/devices.py b/backend/fluksio/api/routes/devices.py new file mode 100644 index 0000000..3851775 --- /dev/null +++ b/backend/fluksio/api/routes/devices.py @@ -0,0 +1,679 @@ +"""Devices: 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 +device's page to say which device it is, or picks the request from the list of +devices waiting on this network. The device polls, collects the credential the +approval minted, and never asks again. + +A screen hanging somewhere this instance is not reachable from does the +same thing through the portal, which forwards the two pairing calls down the +tunnel without a session and mints the credential itself when the approval +comes. Anyone on the internet can ask the portal for a code, so a request that +came that way is adopted by its code only. + +The credential is scoped: ``fluksio.api.deps`` lets it reach the dashboards +that device was assigned, the messages their widgets bind to, and nothing else. +Deleting the device revokes it, and so does unpairing, which bumps the +device's nonce and leaves what it shows as it was. + +``/panels/pair``, ``/panels/pair/{code}`` and ``GET /panels/{id}`` are what the +openpanel firmware calls, and stay as aliases until every device has updated. +""" + +from __future__ import annotations + +import secrets +import time +from datetime import datetime, timedelta +from typing import Any, cast + +import httpx +import redis +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.concurrency import run_in_threadpool +from pydantic import BaseModel, Field, field_validator + +from fluksio.api.deps import ( + CurrentUser, + DeviceStoreDep, + get_current_active_superuser, + get_current_user, +) +from fluksio.cloud import config as cloud_config +from fluksio.core import security +from fluksio.core.config import settings +from fluksio.flow.devices import ( + DeviceDef, + DeviceExists, + DeviceNotFound, + DeviceStore, + is_lan, + presence, +) +from fluksio.flow.events import event_bus +from fluksio.flow.state import CONNECT_TIMEOUT_S, SOCKET_TIMEOUT_S +from fluksio.flow.store import StaleVersion +from fluksio.models import Message, User + +#: Gated per route rather than on the router: the 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="/devices", tags=["devices"]) +#: The calls the firmware makes today, on the same handlers. +panels_router = APIRouter(prefix="/panels", tags=["devices"], include_in_schema=False) + +#: Long, because a device 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 + +#: Ids a device cannot take: the routes beside ``/{device_id}``. +RESERVED = {"pair", "pending"} + +#: How much of what a device says about itself is kept. +HELLO_LENGTH = 64 + + +class _Pending(BaseModel): + """A device waiting to be told what it is.""" + + #: Held by the device, never shown. + secret: str + #: Names the request in the pending list, so it can be adopted without + #: the code being handed to anyone. + request: str = "" + #: Wall-clock rather than monotonic: an entry outlives the process that + #: minted it. + expires: float + token: str = "" + device_id: str = "" + #: What the request looked like, shown to whoever approves it so they can + #: tell the screen in the hall from one they were not expecting. + #: Self-reported and worth what that is worth. + device: str = "" + name: str = "" + board: str = "" + firmware: str = "" + #: Whether it came down the tunnel. A device that reached the portal + #: cannot reach this instance, so its credential has to be minted where it + #: can collect it. + remote: bool = False + #: Whether it may be adopted from the list without its code: asked from a + #: private network, not through the portal. + lan: bool = False + + +#: The entry itself, one key per code, expiring on its own. +_PENDING_KEY = "panels:pair" +#: An index of the live codes, so :data:`MAX_PENDING` means the same thing to +#: every worker. Scored by expiry, which is how it is pruned. +_PENDING_INDEX = "panels:pair:__codes__" + + +class _PendingStore: + """The codes waiting for somebody to say what they are. + + In Redis when there is one, so a code minted before a restart, or by + another process, is still found. Without one there is one process by + definition (the pip install, and the tests), and a dictionary is the same + thing for it. + """ + + def __init__(self) -> None: + self._local: dict[str, _Pending] = {} + self._redis: redis.Redis | None = ( + redis.Redis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + decode_responses=True, + # A silent Redis must fail this request, not hold it open. + socket_timeout=SOCKET_TIMEOUT_S, + socket_connect_timeout=CONNECT_TIMEOUT_S, + health_check_interval=30, + ) + if settings.REDIS_HOST + else None + ) + + def _key(self, code: str) -> str: + return f"{_PENDING_KEY}:{code}" + + def _codes(self) -> list[str]: + """The live codes, the expired ones dropped first.""" + now = time.time() + if self._redis is None: + for code in [c for c, e in self._local.items() if e.expires < now]: + del self._local[code] + return list(self._local) + self._redis.zremrangebyscore(_PENDING_INDEX, "-inf", now) + return list(cast("list[str]", self._redis.zrange(_PENDING_INDEX, 0, -1))) + + def count(self) -> int: + return len(self._codes()) + + def waiting(self) -> list[tuple[str, _Pending]]: + """Every code nobody has approved yet, with its entry.""" + found = [] + for code in self._codes(): + entry = self.get(code) + if entry is not None and not entry.token: + found.append((code, entry)) + return found + + def add(self, code: str, entry: _Pending) -> bool: + """Claim this code, or say that somebody already holds it.""" + if self._redis is None: + if code in self._local: + return False + self._local[code] = entry + return True + if not self._redis.set( + self._key(code), entry.model_dump_json(), ex=PAIR_TTL, nx=True + ): + return False + self._redis.zadd(_PENDING_INDEX, {code: entry.expires}) + return True + + def get(self, code: str) -> _Pending | None: + if self._redis is None: + entry = self._local.get(code) + else: + raw = cast("str | None", self._redis.get(self._key(code))) + entry = _Pending.model_validate_json(raw) if raw else None + return entry if entry is not None and entry.expires >= time.time() else None + + def save(self, code: str, entry: _Pending) -> None: + """Write an approval back, without moving what it expires at.""" + if self._redis is None: + self._local[code] = entry + return + self._redis.set( + self._key(code), + entry.model_dump_json(), + ex=max(1, int(entry.expires - time.time())), + ) + + def drop(self, code: str) -> None: + if self._redis is None: + self._local.pop(code, None) + return + self._redis.delete(self._key(code)) + self._redis.zrem(_PENDING_INDEX, code) + + +_pending = _PendingStore() + + +class PairHello(BaseModel): + """What a device may say about itself when it asks for a code.""" + + name: str = "" + board: str = "" + firmware: str = "" + + @field_validator("name", "board", "firmware", mode="before") + @classmethod + def _trim(cls, value: Any) -> str: + # Cut rather than refused: a device that says too much should still + # get its code. + return str(value or "").strip()[:HELLO_LENGTH] + + +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 device it is for.""" + + access_token: str | None = None + device: str | None = None + #: The same id, for firmware from before devices. + panel: str | None = None + + +class PairRequest(BaseModel): + """Adopt the device showing ``code``, or the pending ``request``.""" + + code: str = "" + request: str = "" + + +class PendingDevice(BaseModel): + """Who is asking, as far as the request itself says.""" + + #: What ``POST /devices/{id}/pair`` takes to adopt it without the code. + request: str = "" + device: str + name: str = "" + board: str = "" + firmware: str = "" + remote: bool = False + #: Through the portal or from outside this network: only its code adopts + #: it, since anyone can put a request there. + needs_code: bool = True + expires_in: int = 0 + + +def _pending_public(entry: _Pending) -> PendingDevice: + return PendingDevice( + request=entry.request, + device=entry.device, + name=entry.name, + board=entry.board, + firmware=entry.firmware, + remote=entry.remote, + needs_code=not entry.lan, + expires_in=max(0, int(entry.expires - time.time())), + ) + + +class DeviceCreate(BaseModel): + title: str = "" + + +class DevicePublic(DeviceDef): + """A device as the Devices tab lists it, with whether it is there.""" + + online: bool = False + #: The last request it made, since this instance started. + last_seen: datetime | None = None + + +class DevicesPublic(BaseModel): + """The devices, and the address a device should be pointed at. + + The address is the server's own, because the browser's origin is not a + reliable answer: an admin working through the portal is on the portal's + origin, and this one is for a screen on this network. + """ + + data: list[DevicePublic] = Field(default_factory=list) + count: int = 0 + #: Whatever this instance was told it is reachable at, the setting the + #: password-reset links are built from too. + frontend_host: str = "" + + +def _describe(request: Request) -> str: + """A line naming the device behind a pairing request. + + # ponytail: the raw user agent, trimmed. Parse it into "iPad · Safari" if + # it reads badly on the approval screen. + """ + agent = request.headers.get("user-agent", "").strip()[:120] + # Resolved from X-Forwarded-For by the proxy-headers middleware, which is + # what makes a call replayed off the tunnel show the device's address and + # stops a device naming any address it likes. + address = request.client.host if request.client else "" + return " · ".join(part for part in (agent or "Unknown device", address) if part) + + +def _mint_at_hub(device_id: str) -> str: + """Ask the portal for this device's credential. + + A device that arrived through the portal cannot reach this instance, so a + token this instance signed would be one it could never present: the portal + verifies what crosses its tunnel against its own key. It mints, we say + which device, and the scope check here decides the rest. + """ + config = cloud_config.load() + if config is None: + raise HTTPException( + status_code=409, + detail="That device came through a portal this instance is no " + "longer enrolled with", + ) + try: + response = httpx.post( + f"{config.portal_url.rstrip('/')}/api/v1/panel-tokens/", + headers={"Authorization": f"Bearer {config.token}"}, + json={"panel": device_id}, + timeout=15.0, + ) + except httpx.HTTPError as exc: + raise HTTPException( + status_code=502, detail=f"Could not reach the portal: {exc}" + ) from exc + if response.status_code != 200: + raise HTTPException( + status_code=502, + detail=f"The portal refused to mint a credential ({response.status_code})", + ) + token: str = response.json()["access_token"] + return token + + +def _not_found(device_id: str) -> HTTPException: + return HTTPException(status_code=404, detail=f"No device named {device_id!r}") + + +def _changed() -> None: + """Tell every device socket that what it shows may have moved. + + An empty name says that much and no more: every screen listening rescopes + and refetches, which is also where an unpaired one meets its 401. + """ + event_bus.publish({"type": "dashboard_changed", "dashboard": "", "ts": time.time()}) + + +# -- the device itself, before it holds anything ----------------------------- + + +def start_pairing(request: Request, body: PairHello | None = None) -> 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 an entry + that expires ten minutes later. Reachable from the internet when this + instance is enrolled with a portal, which is what the cap and the portal's + own per-address limits are between. + """ + if _pending.count() >= MAX_PENDING: + raise HTTPException( + status_code=429, detail="Too many devices are waiting to be paired" + ) + + remote = cloud_config.via_portal(request.headers) + hello = body or PairHello() + entry = _Pending( + secret=secrets.token_urlsafe(16), + request=secrets.token_urlsafe(9), + expires=time.time() + PAIR_TTL, + device=_describe(request), + name=hello.name, + board=hello.board, + firmware=hello.firmware, + remote=remote, + lan=not remote and is_lan(request.client.host if request.client else ""), + ) + # Claiming the code is what settles a collision, rather than looking + # first: between the look and the write sits another worker doing the same. + for _ in range(10): + code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH)) + if _pending.add(code, entry): + break + else: + raise HTTPException(status_code=503, detail="Could not mint a pairing code") + return PairStarted(code=code, secret=entry.secret) + + +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. + """ + 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 the store is a second copy + # of it, and the device has the only one it needs. + _pending.drop(code) + return PairStatus( + access_token=entry.token, device=entry.device_id, panel=entry.device_id + ) + + +router.add_api_route( + "/pair", start_pairing, methods=["POST"], response_model=PairStarted +) +router.add_api_route( + "/pair/{code}", poll_pairing, methods=["GET"], response_model=PairStatus +) +panels_router.add_api_route( + "/pair", start_pairing, methods=["POST"], response_model=PairStarted +) +panels_router.add_api_route( + "/pair/{code}", poll_pairing, methods=["GET"], response_model=PairStatus +) + + +# -- people ------------------------------------------------------------------ + + +@router.get("/", response_model=DevicesPublic, dependencies=[Depends(get_current_user)]) +async def read_devices(store: DeviceStoreDep) -> Any: + """Every device, what each one shows, and whether it is connected.""" + devices = await run_in_threadpool(store.list) + data = [ + DevicePublic( + **device.model_dump(), + online=presence.online(device.id), + last_seen=presence.last_seen(device.id), + ) + for device in devices + ] + return DevicesPublic( + data=data, + count=len(data), + frontend_host=settings.FRONTEND_HOST.rstrip("/"), + ) + + +@router.get( + "/pending", + response_model=list[PendingDevice], + dependencies=[Depends(get_current_active_superuser)], +) +def read_pending() -> Any: + """The devices asking for a code right now, as they describe themselves.""" + waiting = sorted(_pending.waiting(), key=lambda item: -item[1].expires) + return [_pending_public(entry) for _, entry in waiting] + + +@router.get( + "/pair/{code}/device", + response_model=PendingDevice, + dependencies=[Depends(get_current_active_superuser)], +) +def pending_device(code: str) -> Any: + """What is waiting on this code, before anyone says what it is.""" + entry = _pending.get(code.strip().upper()) + if entry is None: + raise HTTPException(status_code=404, detail="No device is waiting on that code") + return _pending_public(entry) + + +@router.post( + "/{device_id}", + response_model=DeviceDef, + dependencies=[Depends(get_current_active_superuser)], +) +async def create_device( + device_id: str, store: DeviceStoreDep, body: DeviceCreate | None = None +) -> Any: + """Add a device. It shows nothing until it is given dashboards.""" + if device_id in RESERVED: + raise HTTPException(status_code=422, detail=f"{device_id!r} is reserved") + try: + device = DeviceDef(id=device_id, title=(body.title if body else "").strip()) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + try: + return await run_in_threadpool(store.create, device) + except DeviceExists: + raise HTTPException(status_code=409, detail=f"{device_id!r} already exists") + + +@router.get( + "/{device_id}", + response_model=DeviceDef, + dependencies=[Depends(get_current_user)], +) +async def read_device(device_id: str, store: DeviceStoreDep) -> Any: + """One device, read by a person or by the device holding its credential. + + The definition alone: a device compares what it reads with what it is + showing, and a field that changes on every request would rebuild its + screen each time. Declared after the pairing routes and ``/pending``, so + neither is mistaken for a device of that name. + """ + device = await run_in_threadpool(store.find, device_id) + if device is None: + raise _not_found(device_id) + return device + + +panels_router.add_api_route( + "/{device_id}", + read_device, + methods=["GET"], + response_model=DeviceDef, + dependencies=[Depends(get_current_user)], +) + + +@router.put( + "/{device_id}", + response_model=DeviceDef, + dependencies=[Depends(get_current_active_superuser)], +) +async def save_device(device_id: str, body: DeviceDef, store: DeviceStoreDep) -> Any: + """Save a device. Takes effect on its next read. + + The body carries the ``version`` it was based on, and a save someone else + has moved past is refused with 409. + """ + if body.id != device_id: + raise HTTPException(status_code=422, detail="The id in the body must match") + try: + saved = await run_in_threadpool(store.write, body, body.version) + except DeviceNotFound: + raise _not_found(device_id) + except StaleVersion as exc: + raise HTTPException( + status_code=409, + detail={ + "message": "Someone else saved this device first", + "current_version": exc.current, + }, + ) + _changed() + return saved + + +@router.delete( + "/{device_id}", + response_model=Message, + dependencies=[Depends(get_current_active_superuser)], +) +async def delete_device(device_id: str, store: DeviceStoreDep) -> Any: + """Remove the device and refuse its credential, wherever it was minted here.""" + try: + await run_in_threadpool(store.delete, device_id) + except DeviceNotFound: + raise _not_found(device_id) + _changed() + return Message(message=f"Deleted {device_id}") + + +def _adopt( + code: str, entry: _Pending, device_id: str, user: User, store: DeviceStore +) -> str: + """Mint the credential the device behind ``entry`` collects on its next poll. + + One minted here names the approver, so what the device does stays + attributable to a person. One minted by the portal names the account this + instance was enrolled with instead. + """ + if entry.remote: + entry.token = _mint_at_hub(device_id) + else: + entry.token = security.create_panel_token( + device_id, user.id, timedelta(days=TOKEN_DAYS), store.nonce(device_id) + ) + entry.device_id = device_id + _pending.save(code, entry) + return f"Paired {entry.name or entry.device} with {device_id}" + + +@router.post( + "/{device_id}/pair", + response_model=Message, + dependencies=[Depends(get_current_active_superuser)], +) +def approve_pairing( + device_id: str, body: PairRequest, current_user: CurrentUser, store: DeviceStoreDep +) -> Any: + """Say which device the one showing this code, or making this request, is.""" + if store.find(device_id) is None: + raise _not_found(device_id) + + if body.code.strip(): + code = body.code.strip().upper() + entry = _pending.get(code) + if entry is None: + raise HTTPException( + status_code=404, + detail="No device is waiting on that code. Check it again.", + ) + elif body.request: + match = next( + ( + (c, e) + for c, e in _pending.waiting() + if secrets.compare_digest(e.request, body.request) + ), + None, + ) + if match is None: + raise HTTPException( + status_code=404, detail="That device is no longer waiting" + ) + code, entry = match + if not entry.lan: + raise HTTPException( + status_code=403, + detail="This request did not come from your network. Enter the " + "code the device shows instead.", + ) + else: + raise HTTPException(status_code=422, detail="Send a code or a request") + + return Message(message=_adopt(code, entry, device_id, current_user, store)) + + +@router.post( + "/{device_id}/unpair", + response_model=Message, + dependencies=[Depends(get_current_active_superuser)], +) +async def unpair_device(device_id: str, store: DeviceStoreDep) -> Any: + """Stop honouring this device's credential, and keep the device. + + The screen hanging there is refused on its next request and goes back to + showing a pairing code, while its dashboards and their arrangement stay. + + A credential the portal minted for a remote screen carries no nonce, so + this does not reach it. That one is revoked at the hub. + """ + if await run_in_threadpool(store.find, device_id) is None: + raise _not_found(device_id) + await run_in_threadpool(store.bump, device_id) + # The screen is still holding a socket. One event and it refetches, which + # is where it meets the 401 that sends it back to the pairing code. + _changed() + return Message(message=f"Unpaired {device_id}") diff --git a/backend/fluksio/api/routes/flows.py b/backend/fluksio/api/routes/flows.py index 251d2b3..925c631 100644 --- a/backend/fluksio/api/routes/flows.py +++ b/backend/fluksio/api/routes/flows.py @@ -36,9 +36,9 @@ from fluksio.core.db import engine from fluksio.flow.artifacts import is_reference from fluksio.flow.controller import NODE_TYPES, FlowController, follow_header from fluksio.flow.dashboards import DashboardStore +from fluksio.flow.devices import DeviceStore, messages_for, presence from fluksio.flow.events import event_bus from fluksio.flow.messages import flow_of, qualify -from fluksio.flow.panels import messages_for, read_config from fluksio.flow.pipeline import ValidationIssue from fluksio.flow.runs import RunRejected from fluksio.flow.schemas import ( @@ -157,22 +157,23 @@ def _endpoints(controller: FlowController, definition: FlowDef) -> list[Endpoint requires=binding["requires"], ) - # A panel's own hardware: an ``in`` port publishes like a control, an + # A device's own hardware: an ``in`` port publishes like a control, an # ``out`` port reads like a tile. The id is the source id the device # publishes under, so its edges pulse for its values. prefix = f"{flow}." - for panel in read_config().panels: - for port in panel.io: + devices: DeviceStore | None = getattr(controller, "devices", None) + for device in devices.list() if devices is not None else []: + for port in device.io: if not port.message.startswith(prefix): continue - key = f"panel:{panel.id}:{port.port}" + key = f"panel:{device.id}:{port.port}" endpoint = found.setdefault( key, Endpoint( kind="panel", id=key, label=port.port, - detail=panel.title or panel.id, + detail=device.title or device.id, ), ) side = endpoint.provides if port.direction == "in" else endpoint.requires @@ -1079,21 +1080,29 @@ def snapshot_payload( } +def device_of(token: str, app: Any) -> str: + """The device this credential stands for, or "" for a person or none.""" + try: + return str(decode_token(token, app=app).get("panel") or "") + except InvalidTokenError: + return "" + + def panel_scope(token: str, app: Any) -> set[str] | None: """The messages this credential is bounded to, or None if it is a person's. - The socket is the one authenticated surface the route check cannot reach — - a handshake has no route to judge — so a panel is bounded by what it is + The socket is the one authenticated surface the route check cannot reach, + a handshake has no route to judge, so a device is bounded by what it is sent instead of by what it asks for. """ - try: - panel = str(decode_token(token).get("panel") or "") - except InvalidTokenError: - return None - if not panel: + device_id = device_of(token, app) + if not device_id: return None store: DashboardStore | None = getattr(app.state, "dashboard_store", None) - return messages_for(panel, store) if store is not None else set() + devices: DeviceStore | None = getattr(app.state, "device_store", None) + if store is None or devices is None: + return set() + return messages_for(devices.find(device_id), store) def event_for_panel(event: dict[str, Any], only: set[str]) -> bool: @@ -1236,13 +1245,15 @@ async def flow_events(websocket: WebSocket, ticket: str = "", token: str = "") - # every other socket and every request is sharing. def _authenticate() -> Any: with Session(engine) as session: - return user_from_token(session, token) + return user_from_token(session, token, app=websocket.app) user = await run_in_threadpool(_authenticate) if user is None: await websocket.close(code=1008) return only = panel_scope(token, websocket.app) + # A device is online for as long as this socket is open. + device = device_of(token, websocket.app) if only is not None else "" # Nothing until a client says it is drawing something: bytes are what this # socket cannot afford to send speculatively. wanted: set[str] = set() @@ -1263,6 +1274,8 @@ async def flow_events(websocket: WebSocket, ticket: str = "", token: str = "") - async with event_bus.subscribe() as queue: receiver = asyncio.create_task(websocket.receive_text()) + if device: + presence.opened(device) try: while True: sender = asyncio.create_task(queue.get()) @@ -1337,3 +1350,5 @@ async def flow_events(websocket: WebSocket, ticket: str = "", token: str = "") - pass finally: receiver.cancel() + if device: + presence.closed(device) diff --git a/backend/fluksio/api/routes/panels.py b/backend/fluksio/api/routes/panels.py deleted file mode 100644 index 8cb6b78..0000000 --- a/backend/fluksio/api/routes/panels.py +++ /dev/null @@ -1,507 +0,0 @@ -"""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. - -A screen hanging somewhere this instance is not reachable from does the -same thing through the portal, which forwards those two calls down the tunnel -without a session — a device with no credential is the whole point of them — -and mints the credential itself when the approval comes. Which side minted it -changes nothing about what it may do: the scope check is here either way. - -The credential is scoped: ``fluksio.api.deps`` lets it reach the dashboards that -panel was assigned, the messages their widgets bind to, and nothing else. -Removing the panel revokes it; so does bumping the panel's nonce, which is how -one screen is re-paired without disturbing what it was showing. -""" - -from __future__ import annotations - -import secrets -import time -from datetime import timedelta -from typing import Any, cast - -import httpx -import redis -from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.concurrency import run_in_threadpool -from pydantic import BaseModel, Field - -from fluksio.api.deps import CurrentUser, get_current_active_superuser, get_current_user -from fluksio.cloud import config as cloud_config -from fluksio.core import security -from fluksio.core.config import settings -from fluksio.flow.events import event_bus -from fluksio.flow.panels import ( - PanelDef, - PanelsConfig, - edit_lock, - find, - read_config, - write_config, -) -from fluksio.flow.state import CONNECT_TIMEOUT_S, SOCKET_TIMEOUT_S -from fluksio.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(BaseModel): - """A device waiting to be told what it is.""" - - #: Held by the device, never shown. - secret: str - #: Wall-clock rather than monotonic: an entry outlives the process that - #: minted it, and monotonic clocks are not comparable across processes. - expires: float - token: str = "" - panel: str = "" - #: What the request looked like, shown to whoever approves the code so - #: they can tell the screen in the hall from one they were not expecting. - #: Self-reported and worth what that is worth. - device: str = "" - #: Whether it came down the tunnel. A device that reached the portal - #: cannot reach this instance, so its credential has to be minted - #: where it can collect it. - remote: bool = False - - -#: The entry itself, one key per code, expiring on its own. -_PENDING_KEY = "panels:pair" -#: An index of the live codes, so :data:`MAX_PENDING` means the same thing to -#: every worker. Scored by expiry, which is how it is pruned. -_PENDING_INDEX = "panels:pair:__codes__" - - -class _PendingStore: - """The codes waiting for somebody to say what they are. - - A device's poll lands on whichever API worker the proxy picked, and a - screen pairing through the portal lands on whichever one holds the tunnel - — so a code minted by one worker has to be findable from the next. Redis - is where this instance already keeps what must outlive a process. - - Without one there is one process by definition (the pip install, and the - tests), and a dictionary is the same thing for it. - """ - - def __init__(self) -> None: - self._local: dict[str, _Pending] = {} - self._redis: redis.Redis | None = ( - redis.Redis( - host=settings.REDIS_HOST, - port=settings.REDIS_PORT, - decode_responses=True, - # A silent Redis must fail this request, not hold it open. - socket_timeout=SOCKET_TIMEOUT_S, - socket_connect_timeout=CONNECT_TIMEOUT_S, - health_check_interval=30, - ) - if settings.REDIS_HOST - else None - ) - - def _key(self, code: str) -> str: - return f"{_PENDING_KEY}:{code}" - - def count(self) -> int: - """How many are waiting, the expired ones dropped first.""" - now = time.time() - if self._redis is None: - for code in [c for c, e in self._local.items() if e.expires < now]: - del self._local[code] - return len(self._local) - self._redis.zremrangebyscore(_PENDING_INDEX, "-inf", now) - return int(cast(int, self._redis.zcard(_PENDING_INDEX))) - - def add(self, code: str, entry: _Pending) -> bool: - """Claim this code, or say that somebody already holds it.""" - if self._redis is None: - if code in self._local: - return False - self._local[code] = entry - return True - if not self._redis.set( - self._key(code), entry.model_dump_json(), ex=PAIR_TTL, nx=True - ): - return False - self._redis.zadd(_PENDING_INDEX, {code: entry.expires}) - return True - - def get(self, code: str) -> _Pending | None: - if self._redis is None: - entry = self._local.get(code) - else: - raw = cast("str | None", self._redis.get(self._key(code))) - entry = _Pending.model_validate_json(raw) if raw else None - return entry if entry is not None and entry.expires >= time.time() else None - - def save(self, code: str, entry: _Pending) -> None: - """Write an approval back, without moving what it expires at.""" - if self._redis is None: - self._local[code] = entry - return - self._redis.set( - self._key(code), - entry.model_dump_json(), - ex=max(1, int(entry.expires - time.time())), - ) - - def drop(self, code: str) -> None: - if self._redis is None: - self._local.pop(code, None) - return - self._redis.delete(self._key(code)) - self._redis.zrem(_PENDING_INDEX, code) - - -_pending = _PendingStore() - - -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 - - -class PendingDevice(BaseModel): - """Who is asking, as far as the request itself says.""" - - device: str - remote: bool = False - - -def _describe(request: Request) -> str: - """A line naming the device behind a pairing request. - - # ponytail: the raw user agent, trimmed. Parse it into "iPad · Safari" if - # it reads badly on the approval screen. - """ - agent = request.headers.get("user-agent", "").strip()[:120] - # Resolved from X-Forwarded-For by the proxy-headers middleware, which is - # what makes a call replayed off the tunnel show the device's address - # rather than localhost — and stops a device naming any address it likes. - address = request.client.host if request.client else "" - return " · ".join(part for part in (agent or "Unknown device", address) if part) - - -def _mint_at_hub(panel_id: str) -> str: - """Ask the portal for this panel's credential. - - A device that arrived through the portal cannot reach this instance, so - a token this instance signed would be one it could never present: the - portal verifies what crosses its tunnel, and it verifies against its own - key. It mints, we say which panel — and the scope check here decides the - rest, on this call and on every later one. - """ - config = cloud_config.load() - if config is None: - raise HTTPException( - status_code=409, - detail="That device came through a portal this instance is no " - "longer enrolled with", - ) - try: - response = httpx.post( - f"{config.portal_url.rstrip('/')}/api/v1/panel-tokens/", - headers={"Authorization": f"Bearer {config.token}"}, - json={"panel": panel_id}, - timeout=15.0, - ) - except httpx.HTTPError as exc: - raise HTTPException( - status_code=502, detail=f"Could not reach the portal: {exc}" - ) from exc - if response.status_code != 200: - raise HTTPException( - status_code=502, - detail=f"The portal refused to mint a credential ({response.status_code})", - ) - token: str = response.json()["access_token"] - return token - - -class PanelsPublic(BaseModel): - """The panels, and the address a device should be pointed at. - - The address is the server's own, because the browser's origin is not a - reliable answer to it: an admin working through the portal is on the - portal's origin, and this one is for a screen on this network. - - An instance enrolled with a portal has a second address, built by the - dialog from what ``/cloud/status`` reports rather than from here — a panel - is not the thing that knows whether remote access is on. - """ - - panels: list[PanelDef] = Field(default_factory=list) - #: What a save of this list has to send back. - version: int = 0 - #: Whatever this instance was told it is reachable at. The same setting - #: the password-reset links are built from, so an instance that has it - #: wrong has it wrong in both places. - frontend_host: str = "" - - -def _public(config: PanelsConfig) -> PanelsPublic: - return PanelsPublic( - panels=config.panels, - version=config.version, - frontend_host=settings.FRONTEND_HOST.rstrip("/"), - ) - - -@router.get("/", response_model=PanelsPublic, dependencies=[Depends(get_current_user)]) -async def read_panels() -> Any: - """Every panel, what each one shows, and where to point a device.""" - return _public(await run_in_threadpool(read_config)) - - -@router.put( - "/", - response_model=PanelsPublic, - 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 along with its assignment. Re-pairing one screen - and keeping the assignment is ``/{panel_id}/unpair`` below. - - The body carries the ``version`` it was based on, and a save someone else - has moved past is refused with 409, as a dashboard's is. Two clients each - writing back the whole list would otherwise drop each other's panels, and - a dropped panel revokes its screen at once. - """ - 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) - - def _save() -> None: - # Read and write under one lock. The nonce belongs to this - # instance, not to whoever is writing the panels back: a client - # holding an older copy must not be able to undo a revocation by - # saving an arrangement — and reading it in a separate step from - # writing it is exactly how an unpair in between was undone. - with edit_lock: - stored = read_config() - if body.version != stored.version: - raise HTTPException( - status_code=409, - detail={ - "message": "Someone else saved the panels first", - "current_version": stored.version, - }, - ) - nonces = {p.id: p.nonce for p in stored.panels} - for panel in body.panels: - panel.nonce = nonces.get(panel.id, 0) - body.version = stored.version + 1 - write_config(body) - - await run_in_threadpool(_save) - # Which dashboards hang on which panel just changed. An empty name says - # that much and no more: every screen listening rescopes and refetches - # what it shows, rather than waiting for whenever it next reads. - event_bus.publish({"type": "dashboard_changed", "dashboard": "", "ts": time.time()}) - return _public(body) - - -@router.post("/pair", response_model=PairStarted) -def start_pairing(request: Request) -> 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 an entry - that expires ten minutes later. Reachable from the internet when this - instance is enrolled with a portal, which is what the cap and the - portal's own per-address limits are between. - """ - if _pending.count() >= MAX_PENDING: - raise HTTPException( - status_code=429, detail="Too many devices are waiting to be paired" - ) - - remote = secrets.compare_digest( - request.headers.get(cloud_config.VIA_HEADER, ""), cloud_config.VIA_PORTAL - ) - entry = _Pending( - secret=secrets.token_urlsafe(16), - expires=time.time() + PAIR_TTL, - device=_describe(request), - remote=remote, - ) - # Claiming the code is what settles a collision, rather than looking first: - # between the look and the write sits another worker doing the same thing. - for _ in range(10): - code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH)) - if _pending.add(code, entry): - break - else: - raise HTTPException(status_code=503, detail="Could not mint a pairing code") - 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. - """ - 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 the store is a second copy - # of it, and the device has the only one it needs. - _pending.drop(code) - return PairStatus(access_token=entry.token, panel=entry.panel) - - -@router.get( - "/pair/{code}/device", - response_model=PendingDevice, - dependencies=[Depends(get_current_active_superuser)], -) -def pending_device(code: str) -> Any: - """What is waiting on this code, before anyone says what it is. - - Approving a code adopts whatever is holding it, so it is worth seeing that - it looks like the screen you just hung. - """ - entry = _pending.get(code.strip().upper()) - if entry is None: - raise HTTPException(status_code=404, detail="No device is waiting on that code") - return PendingDevice(device=entry.device, remote=entry.remote) - - -@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. - - A credential minted here names the approver, so what the panel does stays - attributable to a person rather than to nobody. One minted by the portal — - for a device that reached this instance only through it — names the - account this instance was enrolled with instead, since that is the one - every portal-borne request already acts as. - """ - panel = find(panel_id) - if panel is None: - raise HTTPException(status_code=404, detail=f"No panel named {panel_id!r}") - - code = body.code.strip().upper() - entry = _pending.get(code) - if entry is None: - raise HTTPException( - status_code=404, - detail="No device is waiting on that code — check it again", - ) - - if entry.remote: - entry.token = _mint_at_hub(panel_id) - else: - entry.token = security.create_panel_token( - panel_id, current_user.id, timedelta(days=TOKEN_DAYS), panel.nonce - ) - entry.panel = panel_id - _pending.save(code, entry) - return Message(message=f"Paired {entry.device} with {panel_id}") - - -@router.post( - "/{panel_id}/unpair", - response_model=Message, - dependencies=[Depends(get_current_active_superuser)], -) -async def unpair_panel(panel_id: str) -> Any: - """Stop honouring this panel's credential, and keep the panel. - - Bumping the nonce refuses the screen hanging there on its next request, so - it goes back to showing a pairing code — while the panel, the dashboards it - was assigned and their arrangement stay exactly as they were. Deleting the - panel is still what throws all three away together. - - A credential the portal minted for a remote screen carries no nonce, so - this does not reach it; that one is revoked at the hub. - """ - - def _unpair() -> bool: - with edit_lock: - config = read_config() - panel = next((p for p in config.panels if p.id == panel_id), None) - if panel is None: - return False - panel.nonce += 1 - write_config(config) - return True - - if not await run_in_threadpool(_unpair): - raise HTTPException(status_code=404, detail=f"No panel named {panel_id!r}") - # The screen is still holding a socket. One event and it refetches, which - # is where it meets the 401 that sends it back to the pairing code. - event_bus.publish({"type": "dashboard_changed", "dashboard": "", "ts": time.time()}) - return Message(message=f"Unpaired {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/fluksio/api/routes/search.py b/backend/fluksio/api/routes/search.py index e146bdf..484993e 100644 --- a/backend/fluksio/api/routes/search.py +++ b/backend/fluksio/api/routes/search.py @@ -13,14 +13,14 @@ from fluksio.api.deps import ( get_current_user, ) from fluksio.api.routes.alerts import read_config as read_alerts_config -from fluksio.flow import modules, panels +from fluksio.flow import modules from fluksio.flow.controller import FlowController from fluksio.flow.dashboards import DashboardNotFound, DashboardStore from fluksio.flow.messages import qualify from fluksio.flow.secrets import get_secrets from fluksio.flow.store import FlowNotFound -# A wall panel never reaches this route: ``deps._panel_may`` is a whitelist that +# A device never reaches this route: ``deps._device_may`` is a whitelist that # ends in a 403, and a whole-instance index is the opposite of what a screen # on a wall is allowed to read. router = APIRouter( @@ -34,7 +34,7 @@ Category = Literal[ "output", "dashboard", "widget", - "panel", + "device", "secret", "module", "worker", @@ -132,9 +132,10 @@ def _build( for widget in dashboard.widgets ) + devices = getattr(controller, "devices", None) entries.extend( - SearchEntry(category="panel", name=panel.id, title=panel.title) - for panel in panels.read_config().panels + SearchEntry(category="device", name=device.id, title=device.title) + for device in (devices.list() if devices is not None else []) ) if secrets: diff --git a/backend/fluksio/cloud/connector.py b/backend/fluksio/cloud/connector.py index e981614..4e78e82 100644 --- a/backend/fluksio/cloud/connector.py +++ b/backend/fluksio/cloud/connector.py @@ -638,12 +638,14 @@ class CloudConnector: from fluksio.api.deps import user_from_token from fluksio.api.routes.flows import ( + device_of, event_for_panel, panel_scope, snapshot_payload, ) from fluksio.api.socket_auth import redeem_ticket from fluksio.core.db import engine + from fluksio.flow.devices import presence from fluksio.flow.events import event_bus stream_id = str(frame["id"]) @@ -662,12 +664,14 @@ class CloudConnector: await socket.send(_dump({"op": "ws_close", "id": stream_id, "code": 1008})) return with Session(engine) as session: - if user_from_token(session, token) is None: + if user_from_token(session, token, app=self._app) is None: await socket.send( _dump({"op": "ws_close", "id": stream_id, "code": 1008}) ) return only = panel_scope(token, self._app) + # A remote device is online for as long as this stream is open. + device = device_of(token, self._app) if only is not None else "" await socket.send(_dump({"op": "ws_open_ok", "id": stream_id})) @@ -683,6 +687,8 @@ class CloudConnector: ) ) + if device: + presence.opened(device) try: async with event_bus.subscribe() as queue: while True: @@ -719,6 +725,9 @@ class CloudConnector: await socket.send( _dump({"op": "ws_close", "id": stream_id, "code": 1011}) ) + finally: + if device: + presence.closed(device) async def _hook_stream( self, socket: Any, stream_id: str, path: str, query: str diff --git a/backend/fluksio/core/config.py b/backend/fluksio/core/config.py index 248200a..4613425 100644 --- a/backend/fluksio/core/config.py +++ b/backend/fluksio/core/config.py @@ -25,6 +25,7 @@ DERIVED_PATHS = { "ALERTS_FILE": "alerts.json", "WEBPUSH_FILE": "webpush.json", "PANELS_FILE": "panels.json", + "DEVICES_FILE": "devices.json", "OAUTH_PRIVATE_KEY_FILE": "oauth-key.pem", "CLOUD_CONFIG_FILE": "cloud.json", "PROVISIONERS_FILE": "provisioners.json", @@ -92,10 +93,13 @@ class Settings(BaseSettings): # could take it is attached. Operator-authored, like the alerts beside it, # and absent on an instance that has nowhere to start one. PROVISIONERS_FILE: Path = Path("flow-data/provisioners.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. + # Where panels were kept before they became devices. Read once at startup, + # moved into the flow repository and `DEVICES_FILE`, and renamed + # `.migrated`. PANELS_FILE: Path = Path("flow-data/panels.json") + # Each device's credential nonce and capability report. Outside the flow + # repository, so reverting it can never undo an unpair. + DEVICES_FILE: Path = Path("flow-data/devices.json") # Which interpreter node code runs on. "auto" adopts the venv the engine # was installed into, when it was installed into one and there is no venv # of its own to lose — which is the `pip install fluksio` beside your own diff --git a/backend/fluksio/flow/controller.py b/backend/fluksio/flow/controller.py index 1ee81ad..02f1a59 100644 --- a/backend/fluksio/flow/controller.py +++ b/backend/fluksio/flow/controller.py @@ -492,6 +492,9 @@ class FlowController: # Set by the lifespan once the store exists; the canvas asks the # controller which dashboards are wired into a flow. self.dashboards: Any = None + #: The device store, for the same reason: a device's own ports are + #: drawn on the canvas of the flow they wire into. + self.devices: Any = None self.pipeline: Pipeline | None = None self.loaded: dict[str, LoadedNode] = {} diff --git a/backend/fluksio/flow/devices.py b/backend/fluksio/flow/devices.py new file mode 100644 index 0000000..f081a4a --- /dev/null +++ b/backend/fluksio/flow/devices.py @@ -0,0 +1,445 @@ +"""Devices: the screens and boards paired with this instance. + +A device is a wall panel, a knob, a camera board: something without a keyboard +that pairs with a code, holds a scoped credential and shows an ordered set of +whole dashboards. A hallway tablet and a workshop one carry different sets, the +same dashboard may hang on both, and each dashboard keeps its own canvas, draft +and version. + +The definition is stored in the flow repository as ``_devices/.json`` and +committed like a dashboard, so it is versioned with the dashboards it names. +What a revert must never undo lives outside git, in ``devices.json``: the nonce +a credential is held against, and the device's own report of what it has. +Whether a device is connected right now is kept in memory only. +""" + +from __future__ import annotations + +import ipaddress +import json +import logging +import os +import re +import threading +import time +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal + +from pydantic import BaseModel, Field, TypeAdapter, ValidationError, field_validator + +from fluksio.flow.dashboards import DashboardDef, DashboardNotFound, DashboardStore +from fluksio.flow.events import event_bus +from fluksio.flow.messages import validate_qualified +from fluksio.flow.schemas import _validate_name +from fluksio.flow.store import FlowStore, InvalidName, StaleVersion, checked + +logger = logging.getLogger(__name__) + +#: Sibling of the dashboards, and likewise not a flow. +DEVICE_DIR = "_devices" + +#: Free text the device interprets: ``gpio.4``, ``backlight``, ``mic``. +PORT_PATTERN = re.compile(r"^[a-z0-9_.]+$") + + +class DevicePort(BaseModel): + """One piece of the device's own hardware, wired to a message. + + A wall panel is a node as well as a screen: a button on its GPIO, a + microphone, a speaker. Which ports exist is the device's business, so the + name is free text and ``config`` is passed through to it unread. + """ + + port: str + #: Qualified, ``flow.name``. A flow still has to declare it, as for any + #: widget, before a publish to it lands. + message: str + #: ``in`` is the device publishing into fluksio, ``out`` is the device + #: following the message. + direction: Literal["in", "out"] + config: dict[str, Any] = Field(default_factory=dict) + + @field_validator("port") + @classmethod + def _check_port(cls, value: str) -> str: + if not PORT_PATTERN.fullmatch(value): + raise ValueError("Use lowercase letters, digits, underscores and dots") + return value + + _check_message = field_validator("message")(validate_qualified) + + +class DeviceDef(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 a dashboard + #: someone deleted, and the device skips it. + dashboards: list[str] = Field(default_factory=list) + #: Bigger controls, a bigger rail and no hover states, for a screen that is + #: touched rather than pointed at. A fact about the screen, not about any + #: dashboard on it. + touch: bool = False + #: The device's own inputs and outputs. An ``in`` port may publish whether + #: or not a dashboard is locked, since a lock covers the controls on a + #: dashboard and not the sensors on the wall. An ``out`` port only reads. + io: list[DevicePort] = Field(default_factory=list) + #: Bumped on every save. A save based on an older one is refused. + version: int = 0 + + @field_validator("id") + @classmethod + def _check_id(cls, value: str) -> str: + return _validate_name(value) + + +class DeviceState(BaseModel): + """What this instance keeps about a device outside the flow repository.""" + + #: Which generation of credential the device honours. A token names the + #: nonce it was minted at, so bumping this refuses the screen hanging there + #: and leaves the device and its dashboards as they are. + nonce: int = 0 + #: What the device says it has, written by the device itself. + report: dict[str, Any] | None = None + reported_at: datetime | None = None + + +_STATES = TypeAdapter(dict[str, DeviceState]) + + +class DeviceNotFound(KeyError): + def __init__(self, device_id: str) -> None: + super().__init__(device_id) + self.id = device_id + + +class DeviceExists(ValueError): + def __init__(self, device_id: str) -> None: + super().__init__(device_id) + self.id = device_id + + +class DeviceStore: + """Device definitions in the flow repository, their nonces beside it. + + Shares the flow store's write lock and commit, as the dashboards do, so a + device save cannot interleave with a flow or dashboard save. + """ + + def __init__(self, flows: FlowStore, state_file: Path) -> None: + self.flows = flows + self.root = flows.root / DEVICE_DIR + self.state_file = state_file + # Read-modify-write of a device's version. + self._lock = threading.Lock() + # Read-modify-write of the nonce file. An unpair interleaved with + # anything else writing it must not be undone by that write. + self._state_lock = threading.Lock() + + def _file(self, device_id: str) -> Path: + return self.root / f"{checked(device_id)}.json" + + def _write(self, device: DeviceDef) -> None: + path = self._file(device.id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(device.model_dump_json(indent=2) + "\n") + + def list(self) -> list[DeviceDef]: + found = [] + for path in sorted(self.root.glob("*.json")): + try: + found.append(DeviceDef.model_validate_json(path.read_text())) + except (OSError, ValidationError): + logger.warning("Skipping unreadable device %s", path.name) + return found + + def find(self, device_id: str) -> DeviceDef | None: + """The device by that id, or None if it is gone. + + Read from disk on every call: this is what makes deleting a device + revoke its credential, so it has to see the current file. + """ + try: + path = self._file(device_id) + except InvalidName: + return None + if not path.exists(): + return None + try: + return DeviceDef.model_validate_json(path.read_text()) + except (OSError, ValidationError): + return None + + def create(self, device: DeviceDef) -> DeviceDef: + with self._lock, self.flows._write_lock: + if self._file(device.id).exists(): + raise DeviceExists(device.id) + saved = device.model_copy(update={"version": 1}) + self._write(saved) + self.flows._commit(f"Add device '{device.id}'") + return saved + + def write(self, device: DeviceDef, base_version: int) -> DeviceDef: + """Save a device, refusing a save someone else has moved past.""" + with self._lock, self.flows._write_lock: + current = self.find(device.id) + if current is None: + raise DeviceNotFound(device.id) + if base_version != current.version: + raise StaleVersion(device.id, current.version) + saved = device.model_copy(update={"version": current.version + 1}) + self._write(saved) + self.flows._commit(f"Save device '{device.id}'") + return saved + + def delete(self, device_id: str) -> None: + """Remove the device, and refuse every credential minted for it. + + The nonce is bumped rather than dropped: a device created again under + the same id, or brought back by a revert, must not take the old + screen's credential back with it. + """ + with self.flows._write_lock: + path = self._file(device_id) + if not path.exists(): + raise DeviceNotFound(device_id) + path.unlink() + self.flows._commit(f"Delete device '{device_id}'") + self.bump(device_id) + presence.forget(device_id) + + # -- outside git ------------------------------------------------------- + + def _read_states(self) -> dict[str, DeviceState]: + """Raises on a file that does not parse, rather than reading it as empty. + + Empty would put every nonce back to 0, which honours again every + credential an unpair refused. + """ + if not self.state_file.exists(): + return {} + return _STATES.validate_json(self.state_file.read_bytes()) + + def _write_states(self, states: dict[str, DeviceState]) -> None: + # Through a temporary file, so a crash mid-write cannot leave a torn + # file behind. + self.state_file.parent.mkdir(parents=True, exist_ok=True) + tmp = self.state_file.with_name(self.state_file.name + ".tmp") + tmp.write_bytes(_STATES.dump_json(states, indent=2)) + os.replace(tmp, self.state_file) + + def state(self, device_id: str) -> DeviceState: + return self._read_states().get(device_id, DeviceState()) + + def nonce(self, device_id: str) -> int: + return self.state(device_id).nonce + + def bump(self, device_id: str) -> int: + """Refuse the credential this device holds now. Returns the new nonce.""" + with self._state_lock: + states = self._read_states() + state = states.setdefault(device_id, DeviceState()) + state.nonce += 1 + self._write_states(states) + return state.nonce + + # -- the panels file of an older instance ------------------------------- + + def migrate(self, panels_file: Path) -> None: + """Move ``panels.json`` into the store and the nonce file, once. + + Each panel becomes ``_devices/.json`` at version 1, in one commit, + and its nonce goes to ``devices.json`` (the higher one wins, should + both have it). The file is then renamed ``.migrated``. Running it again + after a crash part-way skips the devices already written. + """ + if not panels_file.exists(): + return + try: + panels = json.loads(panels_file.read_text()).get("panels", []) + except (OSError, ValueError, AttributeError) as exc: + logger.error("Could not read %s to migrate it: %s", panels_file, exc) + return + + devices: list[tuple[DeviceDef, int]] = [] + for raw in panels: + try: + device = DeviceDef.model_validate({**raw, "version": 1}) + except (ValidationError, TypeError) as exc: + logger.error("Not migrating panel %r: %s", raw, exc) + continue + devices.append((device, int(raw.get("nonce") or 0))) + + with self._lock, self.flows._write_lock: + written = [d.id for d, _ in devices if not self._file(d.id).exists()] + for device, _ in devices: + if device.id in written: + self._write(device) + if written: + self.flows._commit("Migrate panels to devices") + with self._state_lock: + states = self._read_states() + for device, nonce in devices: + state = states.setdefault(device.id, DeviceState()) + state.nonce = max(state.nonce, nonce) + self._write_states(states) + panels_file.replace(panels_file.with_name(panels_file.name + ".migrated")) + logger.warning( + "Moved %d panels from %s into devices (%s)", + len(devices), + panels_file, + ", ".join(written) or "all were there already", + ) + + +class Presence: + """Which devices are connected now, and when each was last heard from. + + In memory: a restart forgets it, and a device that is still there + reconnects within seconds. One process serves the API, so one dictionary + is the whole answer. + """ + + def __init__(self) -> None: + self._sockets: dict[str, int] = {} + self._seen: dict[str, float] = {} + self._lock = threading.Lock() + + def seen(self, device_id: str) -> None: + self._seen[device_id] = time.time() + + def opened(self, device_id: str) -> None: + with self._lock: + count = self._sockets.get(device_id, 0) + 1 + self._sockets[device_id] = count + self._seen[device_id] = time.time() + if count == 1: + self._announce(device_id) + + def closed(self, device_id: str) -> None: + with self._lock: + count = max(0, self._sockets.get(device_id, 0) - 1) + if count: + self._sockets[device_id] = count + else: + self._sockets.pop(device_id, None) + self._seen[device_id] = time.time() + if not count: + self._announce(device_id) + + def forget(self, device_id: str) -> None: + with self._lock: + self._sockets.pop(device_id, None) + self._seen.pop(device_id, None) + + def online(self, device_id: str) -> bool: + return self._sockets.get(device_id, 0) > 0 + + def last_seen(self, device_id: str) -> datetime | None: + seen = self._seen.get(device_id) + return datetime.fromtimestamp(seen, UTC) if seen else None + + def _announce(self, device_id: str) -> None: + last = self.last_seen(device_id) + event_bus.publish( + { + "type": "device_status", + "device": device_id, + "online": self.online(device_id), + "last_seen": last.isoformat() if last else None, + "ts": time.time(), + } + ) + + +presence = Presence() + + +def is_lan(address: str) -> bool: + """Whether a request came from a private network rather than the internet.""" + try: + ip = ipaddress.ip_address(address) + except ValueError: + return False + return ip.is_private or ip.is_loopback + + +# -- what a device's credential reaches -------------------------------------- + + +def dashboards_for( + device: DeviceDef | None, store: DashboardStore +) -> list[DashboardDef]: + """The published documents this device shows, in rail order. + + Published, since that is what a device draws. A name that no longer + resolves is skipped, and a device that is gone shows nothing. + + Handed back whole rather than walked here, because what a device may do + with a message depends on the document it came from: a dashboard that says + it is locked entitles a screen to read it and not to touch it. + """ + if device is None: + return [] + found: list[DashboardDef] = [] + for name in device.dashboards: + try: + found.append(store.read(name)) + except DashboardNotFound: + continue + return found + + +def messages_of(defn: DashboardDef) -> set[str]: + """Every message one dashboard reads or writes. + + A dashboard's own bound settings count, not only its widgets': the theme a + panel is driven to is a message no tile on it draws. + """ + names = set(defn.setting_messages) + for widget in defn.widgets: + names.update(widget.messages) + if widget.target: + names.add(widget.target) + return names + + +def requests_of(defn: DashboardDef) -> set[str]: + """The publishes this dashboard makes in order to read. + + A querying chart asks a flow for the series it draws by publishing a + request, so that publish is how the tile reads. Every other message a + dashboard sends comes from a control, which is what locking it turns off, + so this is what a locked dashboard may still send. + """ + return {w.target for w in defn.widgets if w.type == "chart" and w.target} + + +def targets_of(defn: DashboardDef) -> set[str]: + """The messages this dashboard's inputs publish to. + + What a screen showing it may write, a narrower set than what it may read. + """ + return {w.target for w in defn.widgets if w.target} + + +def io_in(device: DeviceDef | None) -> set[str]: + """The messages this device's own hardware publishes to.""" + if device is None: + return set() + return {port.message for port in device.io if port.direction == "in"} + + +def messages_for(device: DeviceDef | None, store: DashboardStore) -> set[str]: + """Every message this device's dashboards and its own I/O read or write. + + Empty for a device that is gone, which is the same answer as "nothing". + """ + own = {port.message for port in device.io} if device else set() + return own | { + name for defn in dashboards_for(device, store) for name in messages_of(defn) + } diff --git a/backend/fluksio/flow/panels.py b/backend/fluksio/flow/panels.py deleted file mode 100644 index 26d4703..0000000 --- a/backend/fluksio/flow/panels.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Panels: which dashboards a given device shows. - -A wall tablet in the hall and one in the workshop want different dashboards, -and the same dashboard may hang on both. Rather than nesting pages inside a -dashboard, a panel names an ordered set of whole dashboards — each keeps its -own canvas, its own draft and its own version, and the device switches between -them through a rail. - -Stored beside the flows rather than in them, like the alerting configuration: -which screen hangs where is the deployment's concern, not any one dashboard's. -""" - -from __future__ import annotations - -import re -import threading -from pathlib import Path -from typing import Any, Literal - -from pydantic import BaseModel, Field, field_validator - -from fluksio.core.config import settings -from fluksio.flow.dashboards import DashboardDef, DashboardNotFound, DashboardStore -from fluksio.flow.messages import validate_qualified -from fluksio.flow.schemas import _validate_name - -#: Free text the device interprets: ``gpio.4``, ``backlight``, ``mic``. -PORT_PATTERN = re.compile(r"^[a-z0-9_.]+$") - - -class PanelPort(BaseModel): - """One piece of the device's own hardware, wired to a message. - - A wall panel is a node as well as a screen: a button on its GPIO, a - microphone, a speaker. Which ports exist is the device's business, so the - name is free text and ``config`` is passed through to it unread. - """ - - port: str - #: Qualified, ``flow.name`` — a flow still has to declare it, as for any - #: widget, before a publish to it lands. - message: str - #: ``in`` is the device publishing into fluksio; ``out`` is the device - #: following the message. - direction: Literal["in", "out"] - config: dict[str, Any] = Field(default_factory=dict) - - @field_validator("port") - @classmethod - def _check_port(cls, value: str) -> str: - if not PORT_PATTERN.fullmatch(value): - raise ValueError("Use lowercase letters, digits, underscores and dots") - return value - - _check_message = field_validator("message")(validate_qualified) - - -class PanelDef(BaseModel): - """One device, and what it shows.""" - - id: str - title: str = "" - #: Ordered. The first one is what the device opens after pairing, and the - #: rail follows this order. A name that no longer resolves is simply a - #: dashboard someone deleted; the panel skips it. - dashboards: list[str] = Field(default_factory=list) - #: Bigger controls, a bigger rail and no hover states, for a screen that is - #: touched rather than pointed at. It belongs to the device rather than to - #: any dashboard: the same dashboard may hang on a hallway tablet and on a - #: desk browser, and only one of them has fingers on it. - touch: bool = False - #: The device's own inputs and outputs. An ``in`` port may publish whether - #: or not a dashboard is locked — a lock covers the controls on a - #: dashboard, not the sensors on the wall — and an ``out`` port only reads. - io: list[PanelPort] = Field(default_factory=list) - #: Which generation of credential this panel honours. A token names the - #: nonce it was minted at, so bumping this refuses the screen currently - #: hanging here and leaves the panel, its dashboards and their arrangement - #: exactly as they are — re-pairing one device without deleting anything. - #: Not settable from outside: a save carries the stored value forward. - nonce: int = 0 - - @field_validator("id") - @classmethod - def _check_id(cls, value: str) -> str: - return _validate_name(value) - - -class PanelsConfig(BaseModel): - """Every panel this instance knows about.""" - - panels: list[PanelDef] = Field(default_factory=list) - #: Bumped on every save; a save based on an older one is refused, as a - #: dashboard's is. A file written before there was one reads as 0. - version: int = 0 - - -#: Held across a read-modify-write of the panels file. -#: -#: Both `save_panels` and `unpair_panel` are one of those, and interleaving -#: them silently undid a revocation: a save that read the file before an -#: unpair wrote it put the old nonce back, and the screen that had just been -#: unpaired kept working. The nonce carry-forward in the save handler was -#: written to make that impossible, and the window between its read and its -#: write is where it happened anyway. -edit_lock = threading.Lock() - - -def _path() -> Path: - return settings.PANELS_FILE - - -def read_config() -> PanelsConfig: - """The stored panels, or none. Blocking.""" - path = _path() - if not path.exists(): - return PanelsConfig() - try: - return PanelsConfig.model_validate_json(path.read_text()) - except Exception: - # A hand-edited file that no longer parses must not lock everyone out. - return PanelsConfig() - - -def write_config(config: PanelsConfig) -> None: - """Blocking.""" - path = _path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(config.model_dump_json(indent=2)) - - -def find(panel_id: str) -> PanelDef | None: - """The panel by that id, or None if it was removed. - - Read from disk on every call: this is what makes deleting a panel revoke - its credential, so it has to see the current file rather than a cache. - """ - for panel in read_config().panels: - if panel.id == panel_id: - return panel - return None - - -def dashboards_for(panel_id: str, store: DashboardStore) -> list[DashboardDef]: - """The published documents this panel shows, in rail order. - - Published, since that is what a panel draws. A name that no longer - resolves is a dashboard someone deleted and is skipped, and a panel that - is gone shows nothing. - - Handed back whole rather than walked here, because what a panel may do - with a message depends on the document it came from — a dashboard that - says it is locked entitles a screen to read it and not to touch it. - """ - panel = find(panel_id) - if panel is None: - return [] - found: list[DashboardDef] = [] - for name in panel.dashboards: - try: - found.append(store.read(name)) - except DashboardNotFound: - continue - return found - - -def messages_of(defn: DashboardDef) -> set[str]: - """Every message one dashboard reads or writes. - - A dashboard's own bound settings count, not only its widgets': the theme a - panel is driven to is a message no tile on it draws, and a wall panel - refused its own theme message is the one surface the setting exists for. - """ - names = set(defn.setting_messages) - for widget in defn.widgets: - names.update(widget.messages) - if widget.target: - names.add(widget.target) - return names - - -def requests_of(defn: DashboardDef) -> set[str]: - """The publishes this dashboard makes in order to read. - - A querying chart asks a flow for the series it draws by publishing a - request, so that publish is how the tile reads rather than something - anyone touched. Every other message a dashboard sends comes from a - control, which is what marking it read-only turns off — so this is what a - locked dashboard is still entitled to send. - """ - return {w.target for w in defn.widgets if w.type == "chart" and w.target} - - -def targets_of(defn: DashboardDef) -> set[str]: - """The messages this dashboard's inputs publish to. - - What a screen showing it may write, a narrower set than what it may read. - A tile that only draws a message, and a setting bound to one, are there to - be looked at, so a panel showing them is not entitled to set them. - """ - return {w.target for w in defn.widgets if w.target} - - -def io_in(panel_id: str) -> set[str]: - """The messages this panel's own hardware publishes to.""" - panel = find(panel_id) - if panel is None: - return set() - return {port.message for port in panel.io if port.direction == "in"} - - -def messages_for(panel_id: str, store: DashboardStore) -> set[str]: - """Every message this panel's dashboards and its own I/O read or write. - - What a screen is entitled to see, as its dashboards and ports define it, - and empty for a panel that is gone — which is the same answer as "nothing". - """ - panel = find(panel_id) - own = {port.message for port in panel.io} if panel else set() - return own | { - name for defn in dashboards_for(panel_id, store) for name in messages_of(defn) - } diff --git a/backend/fluksio/flow/store.py b/backend/fluksio/flow/store.py index 006af18..c239422 100644 --- a/backend/fluksio/flow/store.py +++ b/backend/fluksio/flow/store.py @@ -497,7 +497,8 @@ class FlowStore: """Every flow the editor knows, published or not.""" names = {path.parent.name for path in self.root.glob("*/flow.json")} names |= {path.parent.name for path in self.root.glob("*/flow.draft.json")} - return sorted(names) + # `_devices/flow.json` is a device named "flow", not a flow. + return sorted(name for name in names if not name.startswith("_")) def exists(self, name: str) -> bool: return self._flow_file(name).exists() or self._draft_file(name).exists() diff --git a/backend/fluksio/main.py b/backend/fluksio/main.py index 3f3d4bc..a6e25b2 100644 --- a/backend/fluksio/main.py +++ b/backend/fluksio/main.py @@ -34,6 +34,7 @@ from fluksio.flow.alerts import AlertManager from fluksio.flow.artifacts import ArtifactStore, VolatileStore from fluksio.flow.controller import FlowController, RebuildBusy from fluksio.flow.dashboards import DashboardStore +from fluksio.flow.devices import DeviceStore from fluksio.flow.events import event_bus from fluksio.flow.executor import ExecutionService from fluksio.flow.metrics import MetricsCollector @@ -349,6 +350,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: dashboards = DashboardStore(controller.store) app.state.dashboard_store = dashboards controller.dashboards = dashboards + devices = DeviceStore(controller.store, settings.DEVICES_FILE) + # Once, for an instance from before devices: its panels file moves + # into the repository and the nonce file, and is renamed `.migrated`. + await run_in_threadpool(devices.migrate, settings.PANELS_FILE) + app.state.device_store = devices + controller.devices = devices # Charts need a deeper series than the default; tell the engine # before it starts recording. controller.set_history_limits(dashboards.history_requirements()) diff --git a/backend/fluksio/mcp/server.py b/backend/fluksio/mcp/server.py index bbf2faf..23c46f7 100644 --- a/backend/fluksio/mcp/server.py +++ b/backend/fluksio/mcp/server.py @@ -180,6 +180,19 @@ async def list_shared_nodes() -> Any: return await _call("GET", "/flows/library") +@mcp.tool() +async def list_devices() -> Any: + """Every paired screen or board, the dashboards it shows and whether it is + connected.""" + return await _call("GET", "/devices/") + + +@mcp.tool() +async def get_device(device_id: str) -> Any: + """One device: its dashboards in rail order, touch, and its own I/O ports.""" + return await _call("GET", f"/devices/{_seg(device_id)}") + + @mcp.tool() async def get_graph() -> Any: """Every flow as one graph, with nodes talking to the same thing merged. diff --git a/backend/tests/api/routes/test_panels.py b/backend/tests/api/routes/test_devices.py similarity index 84% rename from backend/tests/api/routes/test_panels.py rename to backend/tests/api/routes/test_devices.py index 7259ef8..1ab87f6 100644 --- a/backend/tests/api/routes/test_panels.py +++ b/backend/tests/api/routes/test_devices.py @@ -1,22 +1,40 @@ -"""Panels: assignment, pairing, and what a paired credential may reach.""" +"""Devices: assignment, pairing, and what a paired credential may reach.""" + +import threading from fastapi.testclient import TestClient +from fluksio.api.routes import devices as devices_route from fluksio.cloud import config as cloud_config from fluksio.core.config import settings from fluksio.main import app -PREFIX = f"{settings.API_V1_STR}/panels" +PREFIX = f"{settings.API_V1_STR}/devices" +#: What the openpanel firmware calls, kept as aliases. +PANELS = f"{settings.API_V1_STR}/panels" DASHBOARDS = f"{settings.API_V1_STR}/dashboards" def _panels(client: TestClient, headers: dict[str, str], config: dict) -> None: - """Replace the panels, based on whatever is stored now.""" - version = client.get(f"{PREFIX}/", headers=headers).json()["version"] - response = client.put( - f"{PREFIX}/", headers=headers, json={**config, "version": version} - ) - assert response.status_code == 200, response.text + """Make the stored devices exactly ``config["panels"]``, one call each.""" + wanted = {device["id"]: device for device in config["panels"]} + stored = client.get(f"{PREFIX}/", headers=headers).json()["data"] + for device in stored: + if device["id"] not in wanted: + gone = client.delete(f"{PREFIX}/{device['id']}", headers=headers) + assert gone.status_code == 200, gone.text + versions = {device["id"]: device["version"] for device in stored} + for device_id, device in wanted.items(): + if device_id not in versions: + created = client.post(f"{PREFIX}/{device_id}", headers=headers) + assert created.status_code == 200, created.text + versions[device_id] = created.json()["version"] + saved = client.put( + f"{PREFIX}/{device_id}", + headers=headers, + json={**device, "version": versions[device_id]}, + ) + assert saved.status_code == 200, saved.text def _dashboard(client: TestClient, headers: dict[str, str], name: str) -> dict: @@ -51,13 +69,14 @@ def _pair(client: TestClient, headers: dict[str, str], panel: str) -> dict[str, collected = client.get( f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]} ).json() - assert collected["panel"] == panel + assert collected["device"] == collected["panel"] == panel return {"Authorization": f"Bearer {collected['access_token']}"} -def test_panels_require_authentication(client: TestClient) -> None: +def test_devices_require_authentication(client: TestClient) -> None: assert client.get(f"{PREFIX}/").status_code == 401 - assert client.put(f"{PREFIX}/", json={"panels": []}).status_code == 401 + assert client.put(f"{PREFIX}/hall", json={"id": "hall"}).status_code == 401 + assert client.get(f"{PREFIX}/pending").status_code == 401 def test_assign_and_read_back( @@ -77,7 +96,9 @@ def test_assign_and_read_back( ) stored = client.get(f"{PREFIX}/", headers=superuser_token_headers).json() - assert stored["panels"][0]["dashboards"] == ["hall_a", "hall_b"] + hall = next(device for device in stored["data"] if device["id"] == "hall") + assert hall["dashboards"] == ["hall_a", "hall_b"] + assert hall["online"] is False # The address a device is pointed at comes from the server, because the # browser's own origin is the portal's when someone administers remotely. assert stored["frontend_host"] == settings.FRONTEND_HOST.rstrip("/") @@ -91,7 +112,7 @@ def test_assign_and_read_back( ) -def test_touch_belongs_to_the_panel( +def test_touch_belongs_to_the_device( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: """Whether a screen is touched is a fact about the screen, not the document. @@ -112,59 +133,61 @@ def test_touch_belongs_to_the_panel( ) by_id = { - panel["id"]: panel - for panel in client.get(f"{PREFIX}/", headers=superuser_token_headers).json()[ - "panels" + device["id"]: device + for device in client.get(f"{PREFIX}/", headers=superuser_token_headers).json()[ + "data" ] } assert by_id["wall"]["touch"] is True - # Absent is pointed at, which is what a panels file written before this - # field existed comes back as. + # Absent is pointed at. assert by_id["desk"]["touch"] is False -def test_duplicate_panel_is_refused( +def test_creating_twice_or_a_reserved_id_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 ( + client.post(f"{PREFIX}/twice", headers=superuser_token_headers).status_code + == 200 + ) + assert ( + client.post(f"{PREFIX}/twice", headers=superuser_token_headers).status_code + == 409 + ) + assert ( + client.post(f"{PREFIX}/pending", headers=superuser_token_headers).status_code + == 422 ) - assert response.status_code == 422 -def test_a_stale_save_is_refused( +def test_concurrent_saves_of_one_version_give_one_409( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: - """Two clients writing back the whole list must not drop each other's panels.""" - read = client.get(f"{PREFIX}/", headers=superuser_token_headers).json() - base = read["version"] + """The race `PUT /panels/` had: one of two saves of the same version wins.""" + created = client.post( + f"{PREFIX}/racy", headers=superuser_token_headers, json={"title": "Racy"} + ).json() + statuses: list[int] = [] - saved = client.put( - f"{PREFIX}/", - headers=superuser_token_headers, - json={"panels": [*read["panels"], {"id": "mine"}], "version": base}, - ) - assert saved.status_code == 200, saved.text - assert saved.json()["version"] == base + 1 + def save(title: str) -> None: + statuses.append( + client.put( + f"{PREFIX}/racy", + headers=superuser_token_headers, + json={**created, "title": title}, + ).status_code + ) - stale = client.put( - f"{PREFIX}/", - headers=superuser_token_headers, - json={"panels": [*read["panels"], {"id": "theirs"}], "version": base}, - ) - assert stale.status_code == 409 - assert stale.json()["detail"]["current_version"] == base + 1 + threads = [threading.Thread(target=save, args=(t,)) for t in ("A", "B")] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + assert sorted(statuses) == [200, 409] - ids = [ - p["id"] - for p in client.get(f"{PREFIX}/", headers=superuser_token_headers).json()[ - "panels" - ] - ] - assert "mine" in ids - assert "theirs" not in ids + stored = client.get(f"{PREFIX}/racy", headers=superuser_token_headers).json() + assert stored["version"] == created["version"] + 1 + assert stored["title"] in ("A", "B") def test_polling_needs_the_secret(client: TestClient) -> None: @@ -224,6 +247,15 @@ def test_paired_panel_reaches_only_what_it_shows( == 200 ) assert client.get(f"{PREFIX}/hall", headers=panel_headers).status_code == 200 + # Where the firmware from before devices reads itself. + assert client.get(f"{PANELS}/hall", headers=panel_headers).status_code == 200 + # Not another device. + _panels( + client, + superuser_token_headers, + {"panels": [{"id": "hall", "dashboards": ["panel_shown"]}, {"id": "other"}]}, + ) + assert client.get(f"{PREFIX}/other", headers=panel_headers).status_code == 403 # But not the catalogue, which is the whole namespace at once and no # screen's business. assert ( @@ -680,8 +712,8 @@ def test_unpairing_a_screen_leaves_its_panel_standing( assert fresh != panel_headers assert client.get(f"{DASHBOARDS}/panel_kept", headers=fresh).status_code == 200 - # A client holding an older copy of the panels cannot undo the revocation - # by writing the nonce back. + # A save of the device cannot undo the revocation: the nonce is not part + # of what is saved. _panels(client, superuser_token_headers, assigned) assert ( client.get(f"{DASHBOARDS}/panel_kept", headers=panel_headers).status_code == 401 @@ -728,6 +760,9 @@ class _FakeRedis: def zcard(self, key: str) -> int: return len(self.zset) + def zrange(self, key: str, start: int, end: int) -> list[str]: + return sorted(self.zset, key=self.zset.__getitem__) + def zrem(self, key: str, member: str) -> None: self.zset.pop(member, None) @@ -740,16 +775,14 @@ def test_a_pending_code_is_found_by_whichever_worker_is_polled() -> None: """A poll lands wherever the proxy sent it, not on the minting worker.""" import time - from fluksio.api.routes import panels as panels_route - shared = _FakeRedis() - minted = panels_route._PendingStore() + minted = devices_route._PendingStore() minted._redis = shared # type: ignore[assignment] - polled = panels_route._PendingStore() + polled = devices_route._PendingStore() polled._redis = shared # type: ignore[assignment] - entry = panels_route._Pending( - secret="s", expires=time.time() + panels_route.PAIR_TTL, device="a tablet" + entry = devices_route._Pending( + secret="s", expires=time.time() + devices_route.PAIR_TTL, device="a tablet" ) assert minted.add("ABC123", entry) # The cap and the collision check mean the same thing on both. @@ -770,7 +803,7 @@ def test_a_pending_code_is_found_by_whichever_worker_is_polled() -> None: assert polled.count() == 0 # An entry past its own expiry is nothing, whatever the key still says. - stale = panels_route._Pending(secret="s", expires=time.time() - 1) + stale = devices_route._Pending(secret="s", expires=time.time() - 1) assert minted.add("STALE1", stale) assert polled.get("STALE1") is None assert polled.count() == 0 @@ -797,6 +830,80 @@ def test_removing_the_panel_revokes_its_credential( client.get(f"{DASHBOARDS}/panel_gone", headers=panel_headers).status_code == 401 ) + # Nor does a device created again under the same id take it back. + _panels( + client, + superuser_token_headers, + {"panels": [{"id": "workshop", "dashboards": ["panel_gone"]}]}, + ) + assert ( + client.get(f"{DASHBOARDS}/panel_gone", headers=panel_headers).status_code == 401 + ) + + +def test_firmware_from_before_devices_pairs_through_the_panel_aliases( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + """The openpanel firmware's exact calls: an empty JSON body, then a poll.""" + _panels(client, superuser_token_headers, {"panels": [{"id": "porch"}]}) + started = client.post( + f"{PANELS}/pair", content=b"", headers={"Content-Type": "application/json"} + ) + assert started.status_code == 200, started.text + code, secret = started.json()["code"], started.json()["secret"] + approved = client.post( + f"{PREFIX}/porch/pair", headers=superuser_token_headers, json={"code": code} + ) + assert approved.status_code == 200, approved.text + collected = client.get(f"{PANELS}/pair/{code}", params={"secret": secret}).json() + assert collected["panel"] == "porch" + headers = {"Authorization": f"Bearer {collected['access_token']}"} + assert client.get(f"{PANELS}/porch", headers=headers).json()["id"] == "porch" + + +def test_a_device_on_this_network_is_adopted_from_the_pending_list( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + """No code to type for a request from the LAN, but one through the portal.""" + _panels(client, superuser_token_headers, {"panels": [{"id": "knob"}]}) + lan = TestClient(app, client=("192.168.1.50", 50000)) + started = lan.post( + f"{PREFIX}/pair", json={"name": "knob-3f2a", "board": "knob", "firmware": "1.4"} + ).json() + remote = client.post( + f"{PREFIX}/pair", headers=_via_portal(), json={"name": "x" * 500} + ).json() + + pending = client.get(f"{PREFIX}/pending", headers=superuser_token_headers).json() + mine = next(entry for entry in pending if entry["name"] == "knob-3f2a") + assert mine["board"] == "knob" and mine["firmware"] == "1.4" + assert mine["needs_code"] is False + portal = next(entry for entry in pending if entry["remote"]) + assert portal["needs_code"] is True + assert len(portal["name"]) == devices_route.HELLO_LENGTH + # The list never hands a code out. + assert started["code"] not in str(pending) and remote["code"] not in str(pending) + + refused = client.post( + f"{PREFIX}/knob/pair", + headers=superuser_token_headers, + json={"request": portal["request"]}, + ) + assert refused.status_code == 403 + + adopted = client.post( + f"{PREFIX}/knob/pair", + headers=superuser_token_headers, + json={"request": mine["request"]}, + ) + assert adopted.status_code == 200, adopted.text + collected = lan.get( + f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]} + ).json() + assert collected["device"] == "knob" + headers = {"Authorization": f"Bearer {collected['access_token']}"} + assert client.get(f"{PREFIX}/knob", headers=headers).status_code == 200 + # -------------------------------------------------------------------------- # A screen that reached the portal but not this instance @@ -855,8 +962,6 @@ def test_a_remote_device_is_paired_at_the_portal( """ import httpx - from fluksio.api.routes import panels as panels_route - calls: list[dict[str, object]] = [] def fake_post(url: str, **kwargs: object) -> httpx.Response: @@ -867,7 +972,7 @@ def test_a_remote_device_is_paired_at_the_portal( request=httpx.Request("POST", url), ) - monkeypatch.setattr(panels_route.httpx, "post", fake_post) + monkeypatch.setattr(devices_route.httpx, "post", fake_post) _panels(client, superuser_token_headers, {"panels": [{"id": "hallway"}]}) started = client.post(f"{PREFIX}/pair", headers=_via_portal()).json() diff --git a/backend/tests/api/routes/test_flows.py b/backend/tests/api/routes/test_flows.py index c6ac2a8..5283802 100644 --- a/backend/tests/api/routes/test_flows.py +++ b/backend/tests/api/routes/test_flows.py @@ -519,7 +519,9 @@ async def test_the_live_socket_ends_when_its_client_goes( background tasks to complete" and nothing else, leaving `serve.pid` behind because only SIGKILL ended the process. This returning is what ends that. """ - monkeypatch.setattr(flows_route, "user_from_token", lambda session, token: object()) + monkeypatch.setattr( + flows_route, "user_from_token", lambda session, token, **_: object() + ) class Peer: """One frame, then gone — the way a closed tab leaves.""" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 35537a3..a6af140 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -37,6 +37,7 @@ def flow_data(tmp_path_factory: pytest.TempPathFactory) -> Generator[None, None, settings.FLOWS_DIR = root / "flows" settings.SECRETS_FILE = root / "secrets.enc" settings.PANELS_FILE = root / "panels.json" + settings.DEVICES_FILE = root / "devices.json" yield diff --git a/backend/tests/flow/test_device_store.py b/backend/tests/flow/test_device_store.py new file mode 100644 index 0000000..e3d96e3 --- /dev/null +++ b/backend/tests/flow/test_device_store.py @@ -0,0 +1,58 @@ +"""The one-time move of an older instance's panels file into devices.""" + +import json +import subprocess +from pathlib import Path + +from fluksio.flow.devices import DeviceStore +from fluksio.flow.store import FlowStore + + +def test_panels_move_into_devices_once(tmp_path: Path) -> None: + flows = FlowStore(tmp_path / "flows") + panels = tmp_path / "panels.json" + panels.write_text( + json.dumps( + { + "panels": [ + {"id": "hall", "title": "Hall", "dashboards": ["home"], "nonce": 2}, + {"id": "knob", "touch": True, "io": [], "nonce": 0}, + ], + "version": 7, + } + ) + ) + store = DeviceStore(flows, tmp_path / "devices.json") + store.migrate(panels) + + assert not panels.exists() + assert (tmp_path / "panels.json.migrated").exists() + hall = store.find("hall") + assert hall is not None and hall.dashboards == ["home"] and hall.version == 1 + assert store.find("knob").touch is True # type: ignore[union-attr] + # The nonce stays outside git, so a credential minted at it still stands. + assert store.nonce("hall") == 2 + assert "nonce" not in (flows.root / "_devices" / "hall.json").read_text() + log = subprocess.run( + ["git", "-C", str(flows.root), "log", "--format=%s"], + capture_output=True, + text=True, + ).stdout + assert log.splitlines()[0] == "Migrate panels to devices" + # Nothing in `_devices` is taken for a flow. + assert flows.list_flows() == [] + + # A second start finds nothing to do, and a panels file restored from a + # backup does not lower a nonce an unpair has raised since. + store.bump("hall") + panels.write_text((tmp_path / "panels.json.migrated").read_text()) + store.migrate(panels) + assert store.nonce("hall") == 3 + assert ( + log + == subprocess.run( + ["git", "-C", str(flows.root), "log", "--format=%s"], + capture_output=True, + text=True, + ).stdout + ) diff --git a/backend/tests/flow/test_endpoints.py b/backend/tests/flow/test_endpoints.py index dcf3bf6..0a5f7c9 100644 --- a/backend/tests/flow/test_endpoints.py +++ b/backend/tests/flow/test_endpoints.py @@ -10,10 +10,9 @@ from pathlib import Path import pytest from fluksio.api.routes.flows import _endpoints -from fluksio.core.config import settings from fluksio.flow.controller import FlowController +from fluksio.flow.devices import DeviceDef, DevicePort, DeviceStore from fluksio.flow.messages import DType, MessageSpec -from fluksio.flow.panels import PanelDef, PanelPort, PanelsConfig, write_config from fluksio.flow.schemas import FlowDef, NodeDef from fluksio.flow.store import FlowStore @@ -113,23 +112,17 @@ def test_the_wiring_index_is_read_once_per_store_revision(controller): assert reads == 2 -def test_a_panel_port_is_drawn_on_the_side_it_wires(controller, tmp_path, monkeypatch): - monkeypatch.setattr(settings, "PANELS_FILE", tmp_path / "panels.json") - write_config( - PanelsConfig( - panels=[ - PanelDef( - id="hall", - title="Hall", - io=[ - PanelPort(port="gpio.4", message="house.temp", direction="in"), - PanelPort( - port="backlight", message="house.temp", direction="out" - ), - PanelPort(port="mic", message="garage.open", direction="in"), - ], - ) - ] +def test_a_device_port_is_drawn_on_the_side_it_wires(controller, tmp_path): + controller.devices = DeviceStore(controller.store, tmp_path / "devices.json") + controller.devices.create( + DeviceDef( + id="hall", + title="Hall", + io=[ + DevicePort(port="gpio.4", message="house.temp", direction="in"), + DevicePort(port="backlight", message="house.temp", direction="out"), + DevicePort(port="mic", message="garage.open", direction="in"), + ], ) ) diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index 791a9b2..8e7fd60 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -24,7 +24,6 @@ from sqlmodel import Session from fluksio.api.deps import decode_token, user_from_token from fluksio.cloud import config as cloud_config from fluksio.core.config import settings -from fluksio.flow.panels import PanelDef, PanelsConfig, write_config from fluksio.models import User from tests.utils.portal import ISSUER, jwks, portal_token from tests.utils.user import create_random_user @@ -261,14 +260,18 @@ def test_a_panel_scoped_portal_token_reaches_only_its_panel( The portal names the panel; everything about what that means is this instance's, which is the whole reason it may mint one at all. """ - write_config( - PanelsConfig( - panels=[ - PanelDef(id="hallway", dashboards=["kitchen"]), - PanelDef(id="workshop", dashboards=["bench"]), - ] + devices = f"{settings.API_V1_STR}/devices" + for device_id, dashboard in (("hallway", "kitchen"), ("workshop", "bench")): + client.delete(f"{devices}/{device_id}", headers=superuser_token_headers) + created = client.post( + f"{devices}/{device_id}", headers=superuser_token_headers + ).json() + saved = client.put( + f"{devices}/{device_id}", + headers=superuser_token_headers, + json={**created, "dashboards": [dashboard]}, ) - ) + assert saved.status_code == 200, saved.text for name in ("kitchen", "bench"): created = client.post( f"{settings.API_V1_STR}/dashboards/{name}", headers=superuser_token_headers @@ -290,10 +293,7 @@ def test_a_panel_scoped_portal_token_reaches_only_its_panel( token = portal_token(portal_key, subject="hallway", scope="panel") headers = {"Authorization": f"Bearer {token}"} - assert ( - client.get(f"{settings.API_V1_STR}/panels/hallway", headers=headers).status_code - == 200 - ) + assert client.get(f"{devices}/hallway", headers=headers).status_code == 200 assert ( client.get( f"{settings.API_V1_STR}/dashboards/kitchen", headers=headers @@ -307,19 +307,14 @@ def test_a_panel_scoped_portal_token_reaches_only_its_panel( ).status_code == 403 ) - assert ( - client.get(f"{settings.API_V1_STR}/panels/", headers=headers).status_code == 403 - ) + assert client.get(f"{devices}/", headers=headers).status_code == 403 assert ( client.get(f"{settings.API_V1_STR}/flows/", headers=headers).status_code == 403 ) - # Deleting the panel is how the screen is retired, whoever minted its token. - write_config(PanelsConfig(panels=[PanelDef(id="workshop", dashboards=["bench"])])) - assert ( - client.get(f"{settings.API_V1_STR}/panels/hallway", headers=headers).status_code - == 401 - ) + # Deleting the device is how the screen is retired, whoever minted its token. + client.delete(f"{devices}/hallway", headers=superuser_token_headers) + assert client.get(f"{devices}/hallway", headers=headers).status_code == 401 def test_a_panel_token_naming_no_panel_is_refused( diff --git a/docker/compose.yml b/docker/compose.yml index 09d11d1..fed45a1 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -123,11 +123,13 @@ services: # credential and the key it pinned, so losing it on a rebuild would mean # enrolling again by hand. Absent until someone connects a portal. - CLOUD_CONFIG_FILE=/data/cloud.json - # Alert routing and the wall-panel pairings. Both are written at runtime, + # Alert routing and the device nonces. Both are written at runtime, # so off the volume they would land in the container's writable layer and - # be lost on the next rebuild — un-pairing every screen. + # be lost on the next rebuild — un-pairing every screen. PANELS_FILE is + # read once, by an instance from before devices, and renamed `.migrated`. - ALERTS_FILE=/data/alerts.json - PANELS_FILE=/data/panels.json + - DEVICES_FILE=/data/devices.json # The push keypair and the browsers subscribed to it. Off the volume, a # rebuild would silently stop every phone being notified: the key they # subscribed against would be gone. diff --git a/docs/code/agents.md b/docs/code/agents.md index daee185..5876a73 100644 --- a/docs/code/agents.md +++ b/docs/code/agents.md @@ -39,6 +39,7 @@ instance from the portal first if this browser has not recently. | Inspect flows | `list_flows`, `get_flow`, `get_node_source`, `list_node_types`, `get_graph`, `list_shared_nodes` | | Inspect values | `get_flow_state`, `get_message_history` | | Inspect the instance | `list_secrets` (names only), `get_modules`, `get_health`, `get_metrics`, `list_failures`, `list_runs` (live cascades) | +| Inspect devices | `list_devices`, `get_device` | | Edit flows | `save_flow`, `save_node_source`, `validate_flow`, `publish_flow`, `discard_draft`, `delete_flow` | | Operate flows | `run_flow`, `trigger_node`, `start_flow`, `stop_flow`, `pause_flow`, `resume_flow`, `cancel_node` | | Install node packages | `apply_modules` | diff --git a/docs/code/api.md b/docs/code/api.md index f1b9001..ef23ffe 100644 --- a/docs/code/api.md +++ b/docs/code/api.md @@ -179,7 +179,7 @@ a run, a current message or a flow input's initial value still references the digest, the call returns `409`. The hourly sweep removes unreferenced bytes on its own, so this is for reclaiming space sooner. -## Dashboards and panels +## Dashboards and devices | Method | Path | Action | |---|---|---| @@ -190,11 +190,16 @@ on its own, so this is for reclaiming space sooner. | `PUT` | `/dashboards/{name}` | save a draft | | `POST` | `/dashboards/{name}/publish`, `/dashboards/{name}/discard`, `/dashboards/{name}/rename` | publish, discard, or rename | | `POST` | `/dashboards/{name}/duplicate` | copy into an unpublished `{name}_copy` | -| `GET`, `PUT` | `/panels/` | read or replace panel assignments; send the current `version` | -| `POST` | `/panels/pair` | start device pairing | -| `GET` | `/panels/pair/{code}` | poll pairing with its `secret` | -| `POST` | `/panels/{id}/pair` | approve the pairing | -| `POST` | `/panels/{id}/unpair` | revoke the panel credential | +| `GET` | `/devices/` | list devices, with `online` and `last_seen` | +| `POST`, `GET`, `PUT`, `DELETE` | `/devices/{id}` | create, read, save or delete one; a save sends the `version` it read and gets 409 when someone saved first | +| `POST` | `/devices/pair` | start device pairing, optionally with `{name, board, firmware}`; unauthenticated | +| `GET` | `/devices/pair/{code}` | poll pairing with its `secret`; answers `device` (and `panel` for older firmware); unauthenticated | +| `GET` | `/devices/pending` | devices waiting for a code, as they describe themselves | +| `POST` | `/devices/{id}/pair` | adopt by `{code}`, or by `{request}` from the pending list for a request from your network | +| `POST` | `/devices/{id}/unpair` | revoke the device credential | + +`/panels/pair`, `/panels/pair/{code}` and `GET /panels/{id}` answer as their +`/devices/` counterparts, for firmware that has not updated yet. ## Secrets, modules, and alerts diff --git a/docs/code/cli.md b/docs/code/cli.md index 8738dc9..42454b3 100644 --- a/docs/code/cli.md +++ b/docs/code/cli.md @@ -366,7 +366,7 @@ directory. Common choices: | `FLUKSIO_HOME` | Select the CLI's data directory | | `NODE_VENV` | `auto` adopts an existing venv; `managed` requests a separate one; a path selects an interpreter | | `REDIS_HOST` | Use Redis for flow state and queues | -| `FRONTEND_HOST` | Browser address used in mail, OAuth metadata and panel links | +| `FRONTEND_HOST` | Browser address used in mail, OAuth metadata and device links | | `ENVIRONMENT` | `production` disables interactive API documentation | | `MCP_ENABLED` | Enable the [agent endpoint](agents.md) | diff --git a/docs/getting-started/facility-automation.md b/docs/getting-started/facility-automation.md index 79caed6..ff0113a 100644 --- a/docs/getting-started/facility-automation.md +++ b/docs/getting-started/facility-automation.md @@ -111,7 +111,7 @@ Create a dashboard and add a gauge bound to `house.living_temperature`. Publish the dashboard to see the current reading. Dashboard controls can also publish messages, so choose their bindings with care. -See [Dashboards and panels](../interface/dashboards.md) for widgets and pairing +See [Dashboards and devices](../interface/dashboards.md) for widgets and pairing a wall tablet. For longer-term history, add an **InfluxDB** node that consumes diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index d0c812d..120d3b3 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -1,8 +1,8 @@ -# Dashboards and panels +# Dashboards and devices A **dashboard** is a grid of widgets bound to message names. It can show values -and send controls across several flows. A **panel** assigns published dashboards -to a paired screen, such as a wall tablet. +and send controls across several flows. A **device** is a paired screen, such +as a wall tablet or a knob, and shows an ordered set of published dashboards. ## Building one @@ -441,46 +441,61 @@ under the finger, as a tile view moves. ## Showing one Open `/view/{name}` for a dashboard-only view with your normal session. -For a dedicated screen, pair a panel. +For a dedicated screen, pair a device. -## Panels: hanging a screen on a wall +## Devices: hanging a screen on a wall As a superuser: -1. Open **Dashboards → Panels**, add a panel and select its dashboards. The - name you add it under sets its address, `/panel/{id}`, and a later change of - title keeps it. -2. Enable **Touch friendly** for larger controls. -3. Open the displayed pairing link on the device. It shows a six-character code. -4. Enter that code for the panel, check the device description, then press - **Pair device**. +1. Open **Devices → Add device** and name it after where it hangs. The name + sets its address, `/panel/{id}`, and a later change of title keeps it. +2. Under **Dashboards**, add the ones it shows. The first is what it opens + with, and the arrows set the order of its rail. +3. Enable **Touch friendly** for larger controls. +4. Open the pairing link on the device. It shows a six-character code. +5. Enter that code under **Pairing**, check the device description, then press + **Pair**. A device asking from your own network also appears in the list + below, with what it says about itself, and **Pair this one** adopts it + without the code. The screen collects its credential and shows the assigned published dashboards. -Several dashboards get a navigation rail. +Several dashboards get a navigation rail. The page says whether the device is +online, which it is while its live socket is open, and when it was last seen +since the instance started. -A panel credential reads its assigned dashboards, their bound messages and the +Each save carries the version it was read at. When someone else saved the +device first, the save is refused and the page shows their version. + +A device credential reads its assigned dashboards, their bound messages and the artifacts they show. It writes only the targets of their controls and query charts, and the `in` ports of its own [I/O](#the-devices-own-io). A message its tiles only draw, or a dashboard setting is bound to, stays read only. On a locked dashboard only the query charts may still publish. -**Unpair screen** revokes a locally paired credential and keeps the panel's -dashboard assignments. Deleting the panel removes the assignment and its -access. +**Unpair screen** revokes a locally paired credential and keeps the device and +its dashboards. Deleting the device removes the assignment and its access, and +a device added again under the same name does not bring the old credential +back. + +The device definitions are stored in the flow repository (`_devices/.json`), +so their history is in git with the flows. The credential generation lives in +`DEVICES_FILE` outside it, so reverting the repository never undoes an unpair. +An instance from before devices moves its `panels.json` there on its first +start and renames the file `panels.json.migrated`. ### A screen without a browser The openpanel firmware, kept in its own repository, draws dashboards as native widgets on an ESP32-P4 wall panel (Waveshare ESP32-P4-WIFI6-Touch-LCD-7B, -1024×600). Once its config page points it at this instance (the dialog's link -works too, the device keeps only its address), it shows the pairing code itself; enter it as in step 4. It reads the same published +1024×600). Once its config page points it at this instance (the device's link +works too, the device keeps only its address), it shows the pairing code itself; enter it as in step 5. It reads the same published dashboards and live values and draws every widget type with two exceptions, both shown as a placeholder: a website tile that embeds a page (image tiles -work), and a chart of runs, which a panel credential may not read. Media tiles +work), and a chart of runs, which a device credential may not read. Media tiles play MJPEG streams, pushed frames, images, and H.264 Baseline or MJPEG clips up to 640×360. Re-encode other clips with the [clip recipe](../code/nodes.md#making-clips-playable-on-an-lvgl-panel). -The device decides what the panel's [I/O](#the-devices-own-io) port names +The device decides what its [I/O](#the-devices-own-io) port names mean. openpanel serves `gpio.`, `backlight`, `touch`, `mic` and `speaker`, lists each one's `config` in its README and ignores any other name. A browser screen serves none of them. The [speech recipe](../code/nodes.md#speech-to-and-from-a-panel) @@ -488,9 +503,8 @@ turns `mic` into text and text into `speaker`. ### The device's own I/O -A panel can also act as a node. Under **I/O**, wire a port on the device to a -message. A new port saves once it has a port name and a message, at the latest -when the dialog closes: +A device can also act as a node. Under **I/O**, wire a port on the device to a +message. A new port saves once it has a port name and a message: | Field | Meaning | |---|---| @@ -515,12 +529,14 @@ by the nodes that produce it. ### A screen somewhere you cannot reach An [enrolled instance](portal.md) also shows a portal pairing link. Use that -link on the remote screen, then approve its code in the same dialog. The -device description says *via portal*. +link on the remote screen, then approve its code on the device's page. The +device description says *via portal*. Anyone can ask the portal for a code, so +a request that came through it, or from outside your network, is adopted by +its code only and never from the list. The instance enforces the same dashboard and message limits. Remote credentials are issued by the portal, so **Unpair screen** does not revoke -them; revoke them at the portal. Deleting the panel removes access on the +them; revoke them at the portal. Deleting the device removes access on the instance. ## See also diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index 56d2620..28e4d9c 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -13,13 +13,13 @@ below, so a large flow grows downwards in one column. - **Edges** are routed around the nodes, a loop back to an earlier node included, and pulse when values pass through. Click one to inspect its payload. -- **Endpoints** show flow inputs, dashboard widgets, panel I/O ports and nodes +- **Endpoints** show flow inputs, dashboard widgets, device I/O ports and nodes in other flows that exchange messages with this flow. A value passing lights one up in a sweep: left to right when it sends into the flow, right to left when it reads out of it. - **Clicking an endpoint** shows a flow input's type, starting value and - current value, with a pencil that opens the flow settings to edit it. A panel - port opens its panel in the Panels dialog. A node in another flow opens that + current value, with a pencil that opens the flow settings to edit it. A device + port opens the device in the Devices tab. A node in another flow opens that flow on the label leading back here, with its edges highlighted. Values update over a websocket. A connection banner tells you when the editor diff --git a/docs/interface/index.md b/docs/interface/index.md index ffa5389..74b65cf 100644 --- a/docs/interface/index.md +++ b/docs/interface/index.md @@ -13,7 +13,8 @@ The sidebar opens as a sheet on phones. |---|---| | **Home** | see the brain graph, health and recent activity | | **Flows** | create flows and open their editors | -| **Dashboards** | build widget dashboards and pair wall panels | +| **Dashboards** | build widget dashboards | +| **Devices** | pair wall screens and choose the dashboards each shows | | **Runs** | start batch runs and review their results | | **Workers** | manage machines that execute nodes | | **Secrets** | store credentials; superusers only | @@ -29,7 +30,7 @@ the docs in another tab. ### Search Use **Search**, or ⌘K / Ctrl-K, to find flows, nodes, flow inputs and outputs, -dashboards, widgets, panels, secrets, modules, workers and alert channels. +dashboards, widgets, devices, secrets, modules, workers and alert channels. Selecting a result opens its editor or settings. Search covers the current instance. When connected through a portal, @@ -91,6 +92,6 @@ Light and dark follow your system by default. Change this under ## Next stops - [The flow editor](flow-editor.md) -- [Dashboards and panels](dashboards.md) +- [Dashboards and devices](dashboards.md) - [Secrets, modules and alerts](operations.md) - [Accounts and the portal](portal.md) diff --git a/docs/interface/portal.md b/docs/interface/portal.md index ac0cb26..e09364c 100644 --- a/docs/interface/portal.md +++ b/docs/interface/portal.md @@ -115,5 +115,5 @@ service is not included, so the Docker stack does not start one. ## See also - [The fluksio command](../code/cli.md): data directories and enrollment options. -- [Dashboards and panels](dashboards.md): pair a wall screen through the portal. +- [Dashboards and devices](dashboards.md): pair a wall screen through the portal. - [Getting started](../getting-started/index.md): install and open your first instance. diff --git a/docs/llms.txt b/docs/llms.txt index 1a28856..5e3b941 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -22,7 +22,7 @@ - [The dashboard app](/interface/): the shell, Home, health and activity - [The flow editor](/interface/flow-editor/): canvas, node panel, dock, shortcuts, drafts -- [Dashboards and panels](/interface/dashboards/): widgets, typed bindings, wall screens +- [Dashboards and devices](/interface/dashboards/): widgets, typed bindings, wall screens - [Secrets, modules and alerts](/interface/operations/): credentials, packages, failure routing - [Accounts and the portal](/interface/portal/): enrolment, remote users, revoking diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 1db44db..d35c21a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -23,7 +23,8 @@ For CLI installations, select storage with `--data-dir`, `FLUKSIO_HOME` or | `ALERTS_FILE` | `$DATA_DIR/alerts.json` | alert channels and rules | | `WEBPUSH_FILE` | `$DATA_DIR/webpush.json` | push keys and browser subscriptions | | `PROVISIONERS_FILE` | `$DATA_DIR/provisioners.json` | optional worker provisioning configuration | -| `PANELS_FILE` | `$DATA_DIR/panels.json` | wall-panel pairings | +| `DEVICES_FILE` | `$DATA_DIR/devices.json` | device credential nonces, outside the flow repository | +| `PANELS_FILE` | `$DATA_DIR/panels.json` | panels of an instance from before devices, moved into devices on first start and renamed `.migrated` | | `OAUTH_PRIVATE_KEY_FILE` | `$DATA_DIR/oauth-key.pem` | agent and worker signing key | | `CLOUD_CONFIG_FILE` | `$DATA_DIR/cloud.json` | optional portal enrollment | | `AI_CONFIG_FILE` | `$DATA_DIR/ai.json` | optional assistant provider configuration | diff --git a/docs/reference/payload-types.md b/docs/reference/payload-types.md index ecca900..b403672 100644 --- a/docs/reference/payload-types.md +++ b/docs/reference/payload-types.md @@ -170,4 +170,4 @@ port and received value. It is not published downstream. - [Flows, nodes and messages](../concepts/flows.md) - [Node types](node-types.md) -- [Dashboards and panels](../interface/dashboards.md) +- [Dashboards and devices](../interface/dashboards.md) diff --git a/frontend/scripts/capture-screenshots.mjs b/frontend/scripts/capture-screenshots.mjs index 13869b3..d251202 100644 --- a/frontend/scripts/capture-screenshots.mjs +++ b/frontend/scripts/capture-screenshots.mjs @@ -89,7 +89,7 @@ for (const theme of ["light", "dark"]) { await captureFlows(page, dir) await captureDashboards(page, dir) - await capturePanels(page, dir) + await captureDevices(page, dir) await captureMedia(page, dir) await captureRound(page, dir) await captureRuns(page, dir) @@ -97,7 +97,7 @@ for (const theme of ["light", "dark"]) { await context.close() console.log( - ` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel,app-panels,app-round,app-runs,app-run,app-run-context,app-workers}.png`, + ` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel,app-devices,app-device,app-round,app-runs,app-run,app-run-context,app-workers}.png`, ) } } @@ -203,15 +203,20 @@ async function captureDashboards(page, dir) { await page.screenshot({ path: `${dir}/app-panel.png` }) } -/** The panels dialog, scrolled to the first panel's I/O when there is one. */ -async function capturePanels(page, dir) { - await page.goto(`${APP_URL}/dashboards?panels=true`, { - waitUntil: "networkidle", - }) - const io = page.locator("[data-testid^=add-port-]").first() - if (await io.count()) await io.scrollIntoViewIfNeeded() +/** The Devices tab, and one device: the Demo's when it is seeded. */ +async function captureDevices(page, dir) { + await page.goto(`${APP_URL}/devices`, { waitUntil: "networkidle" }) await page.waitForTimeout(1000) - await page.screenshot({ path: `${dir}/app-panels.png` }) + await page.screenshot({ path: `${dir}/app-devices.png` }) + + const demo = page.locator("a[href$='/devices/demo']") + const card = (await demo.count()) ? demo : page.getByTestId("device-card") + if (!(await card.count())) return + await card.first().click() + await page.getByTestId("device-presence").waitFor({ timeout: 15000 }) + await page.waitForLoadState("networkidle") + await page.waitForTimeout(1000) + await page.screenshot({ path: `${dir}/app-device.png`, fullPage: true }) } /** diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index f8f54a1..88306bb 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -911,6 +911,179 @@ export const DeadLetterSchema = { title: 'DeadLetter' } as const; +export const DeviceCreateSchema = { + properties: { + title: { + type: 'string', + title: 'Title', + default: '' + } + }, + type: 'object', + title: 'DeviceCreate' +} as const; + +export const DeviceDefSchema = { + properties: { + id: { + type: 'string', + title: 'Id' + }, + title: { + type: 'string', + title: 'Title', + default: '' + }, + dashboards: { + items: { + type: 'string' + }, + type: 'array', + title: 'Dashboards' + }, + touch: { + type: 'boolean', + title: 'Touch', + default: false + }, + io: { + items: { + '$ref': '#/components/schemas/DevicePort' + }, + type: 'array', + title: 'Io' + }, + version: { + type: 'integer', + title: 'Version', + default: 0 + } + }, + type: 'object', + required: ['id'], + title: 'DeviceDef', + description: 'One device, and what it shows.' +} as const; + +export const DevicePortSchema = { + properties: { + port: { + type: 'string', + title: 'Port' + }, + message: { + type: 'string', + title: 'Message' + }, + direction: { + type: 'string', + enum: ['in', 'out'], + title: 'Direction' + }, + config: { + additionalProperties: true, + type: 'object', + title: 'Config' + } + }, + type: 'object', + required: ['port', 'message', 'direction'], + title: 'DevicePort', + description: `One piece of the device's own hardware, wired to a message. + +A wall panel is a node as well as a screen: a button on its GPIO, a +microphone, a speaker. Which ports exist is the device's business, so the +name is free text and \`\`config\`\` is passed through to it unread.` +} as const; + +export const DevicePublicSchema = { + properties: { + id: { + type: 'string', + title: 'Id' + }, + title: { + type: 'string', + title: 'Title', + default: '' + }, + dashboards: { + items: { + type: 'string' + }, + type: 'array', + title: 'Dashboards' + }, + touch: { + type: 'boolean', + title: 'Touch', + default: false + }, + io: { + items: { + '$ref': '#/components/schemas/DevicePort' + }, + type: 'array', + title: 'Io' + }, + version: { + type: 'integer', + title: 'Version', + default: 0 + }, + online: { + type: 'boolean', + title: 'Online', + default: false + }, + last_seen: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Last Seen' + } + }, + type: 'object', + required: ['id'], + title: 'DevicePublic', + description: 'A device as the Devices tab lists it, with whether it is there.' +} as const; + +export const DevicesPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/DevicePublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count', + default: 0 + }, + frontend_host: { + type: 'string', + title: 'Frontend Host', + default: '' + } + }, + type: 'object', + title: 'DevicesPublic', + description: `The devices, and the address a device should be pointed at. + +The address is the server's own, because the browser's origin is not a +reliable answer: an admin working through the portal is on the portal's +origin, and this one is for a screen on this network.` +} as const; + export const EndpointSchema = { properties: { kind: { @@ -2924,16 +3097,45 @@ export const OAuthClientRegisterSchema = { description: 'RFC 7591 dynamic client registration request.' } as const; +export const PairHelloSchema = { + properties: { + name: { + type: 'string', + title: 'Name', + default: '' + }, + board: { + type: 'string', + title: 'Board', + default: '' + }, + firmware: { + type: 'string', + title: 'Firmware', + default: '' + } + }, + type: 'object', + title: 'PairHello', + description: 'What a device may say about itself when it asks for a code.' +} as const; + export const PairRequestSchema = { properties: { code: { type: 'string', - title: 'Code' + title: 'Code', + default: '' + }, + request: { + type: 'string', + title: 'Request', + default: '' } }, type: 'object', - required: ['code'], - title: 'PairRequest' + title: 'PairRequest', + description: 'Adopt the device showing ``code``, or the pending ``request``.' } as const; export const PairStartedSchema = { @@ -2971,6 +3173,17 @@ export const PairStatusSchema = { ], title: 'Access Token' }, + device: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Device' + }, panel: { anyOf: [ { @@ -2985,145 +3198,49 @@ export const PairStatusSchema = { }, 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' - }, - touch: { - type: 'boolean', - title: 'Touch', - default: false - }, - io: { - items: { - '$ref': '#/components/schemas/PanelPort' - }, - type: 'array', - title: 'Io' - }, - nonce: { - type: 'integer', - title: 'Nonce', - default: 0 - } - }, - type: 'object', - required: ['id'], - title: 'PanelDef', - description: 'One device, and what it shows.' -} as const; - -export const PanelPortSchema = { - properties: { - port: { - type: 'string', - title: 'Port' - }, - message: { - type: 'string', - title: 'Message' - }, - direction: { - type: 'string', - enum: ['in', 'out'], - title: 'Direction' - }, - config: { - additionalProperties: true, - type: 'object', - title: 'Config' - } - }, - type: 'object', - required: ['port', 'message', 'direction'], - title: 'PanelPort', - description: `One piece of the device's own hardware, wired to a message. - -A wall panel is a node as well as a screen: a button on its GPIO, a -microphone, a speaker. Which ports exist is the device's business, so the -name is free text and \`\`config\`\` is passed through to it unread.` -} as const; - -export const PanelsConfigSchema = { - properties: { - panels: { - items: { - '$ref': '#/components/schemas/PanelDef' - }, - type: 'array', - title: 'Panels' - }, - version: { - type: 'integer', - title: 'Version', - default: 0 - } - }, - type: 'object', - title: 'PanelsConfig', - description: 'Every panel this instance knows about.' -} as const; - -export const PanelsPublicSchema = { - properties: { - panels: { - items: { - '$ref': '#/components/schemas/PanelDef' - }, - type: 'array', - title: 'Panels' - }, - version: { - type: 'integer', - title: 'Version', - default: 0 - }, - frontend_host: { - type: 'string', - title: 'Frontend Host', - default: '' - } - }, - type: 'object', - title: 'PanelsPublic', - description: `The panels, and the address a device should be pointed at. - -The address is the server's own, because the browser's origin is not a -reliable answer to it: an admin working through the portal is on the -portal's origin, and this one is for a screen on this network. - -An instance enrolled with a portal has a second address, built by the -dialog from what \`\`/cloud/status\`\` reports rather than from here — a panel -is not the thing that knows whether remote access is on.` + description: 'Nothing yet, or the credential and the device it is for.' } as const; export const PendingDeviceSchema = { properties: { + request: { + type: 'string', + title: 'Request', + default: '' + }, device: { type: 'string', title: 'Device' }, + name: { + type: 'string', + title: 'Name', + default: '' + }, + board: { + type: 'string', + title: 'Board', + default: '' + }, + firmware: { + type: 'string', + title: 'Firmware', + default: '' + }, remote: { type: 'boolean', title: 'Remote', default: false + }, + needs_code: { + type: 'boolean', + title: 'Needs Code', + default: true + }, + expires_in: { + type: 'integer', + title: 'Expires In', + default: 0 } }, type: 'object', @@ -3772,7 +3889,7 @@ export const SearchEntrySchema = { properties: { category: { type: 'string', - enum: ['flow', 'node', 'input', 'output', 'dashboard', 'widget', 'panel', 'secret', 'module', 'worker', 'alert'], + enum: ['flow', 'node', 'input', 'output', 'dashboard', 'widget', 'device', 'secret', 'module', 'worker', 'alert'], title: 'Category' }, name: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 63af7da..2193e88 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 { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadControlNodesResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, ArtifactsDeleteArtifactData, ArtifactsDeleteArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudReadMembersResponse, CloudLinkMemberData, CloudLinkMemberResponse, CloudRevokeMemberData, CloudRevokeMemberResponse, CloudDisconnectResponse, ConnectorsReadConnectorStateData, ConnectorsReadConnectorStateResponse, ConnectorsClearConnectorStateData, ConnectorsClearConnectorStateResponse, ConnectorsDeleteConnectorStateData, ConnectorsDeleteConnectorStateResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsDuplicateDashboardData, DashboardsDuplicateDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsDuplicateFlowData, FlowsDuplicateFlowResponse, 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, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, FlowsIssueSocketTicketResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadNodeTypeTimingsData, ObservabilityReadNodeTypeTimingsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, ObservabilityFlushMetricsResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsReadNodeOutputsData, RunsReadNodeOutputsResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, 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, UtilsReadConfigResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersReadTokensResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersRevokeTokenData, WorkersRevokeTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; +import type { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadControlNodesResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, ArtifactsDeleteArtifactData, ArtifactsDeleteArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudReadMembersResponse, CloudLinkMemberData, CloudLinkMemberResponse, CloudRevokeMemberData, CloudRevokeMemberResponse, CloudDisconnectResponse, ConnectorsReadConnectorStateData, ConnectorsReadConnectorStateResponse, ConnectorsClearConnectorStateData, ConnectorsClearConnectorStateResponse, ConnectorsDeleteConnectorStateData, ConnectorsDeleteConnectorStateResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsDuplicateDashboardData, DashboardsDuplicateDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, DevicesStartPairingData, DevicesStartPairingResponse, DevicesPollPairingData, DevicesPollPairingResponse, DevicesReadDevicesResponse, DevicesReadPendingResponse, DevicesPendingDeviceData, DevicesPendingDeviceResponse, DevicesCreateDeviceData, DevicesCreateDeviceResponse, DevicesReadDeviceData, DevicesReadDeviceResponse, DevicesSaveDeviceData, DevicesSaveDeviceResponse, DevicesDeleteDeviceData, DevicesDeleteDeviceResponse, DevicesApprovePairingData, DevicesApprovePairingResponse, DevicesUnpairDeviceData, DevicesUnpairDeviceResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsDuplicateFlowData, FlowsDuplicateFlowResponse, 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, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, FlowsIssueSocketTicketResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadNodeTypeTimingsData, ObservabilityReadNodeTypeTimingsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, ObservabilityFlushMetricsResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsReadNodeOutputsData, RunsReadNodeOutputsResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, 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, UtilsReadConfigResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersReadTokensResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersRevokeTokenData, WorkersRevokeTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; export class AiService { /** @@ -804,6 +804,259 @@ export class DashboardsService { } } +export class DevicesService { + /** + * 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 an entry + * that expires ten minutes later. Reachable from the internet when this + * instance is enrolled with a portal, which is what the cap and the portal's + * own per-address limits are between. + * @param data The data for the request. + * @param data.requestBody + * @returns PairStarted Successful Response + * @throws ApiError + */ + public static startPairing(data: DevicesStartPairingData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/devices/pair', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * 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: DevicesPollPairingData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/devices/pair/{code}', + path: { + code: data.code + }, + query: { + secret: data.secret + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Devices + * Every device, what each one shows, and whether it is connected. + * @returns DevicesPublic Successful Response + * @throws ApiError + */ + public static readDevices(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/devices/' + }); + } + + /** + * Read Pending + * The devices asking for a code right now, as they describe themselves. + * @returns PendingDevice Successful Response + * @throws ApiError + */ + public static readPending(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/devices/pending' + }); + } + + /** + * Pending Device + * What is waiting on this code, before anyone says what it is. + * @param data The data for the request. + * @param data.code + * @returns PendingDevice Successful Response + * @throws ApiError + */ + public static pendingDevice(data: DevicesPendingDeviceData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/devices/pair/{code}/device', + path: { + code: data.code + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Device + * Add a device. It shows nothing until it is given dashboards. + * @param data The data for the request. + * @param data.deviceId + * @param data.requestBody + * @returns DeviceDef Successful Response + * @throws ApiError + */ + public static createDevice(data: DevicesCreateDeviceData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/devices/{device_id}', + path: { + device_id: data.deviceId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Device + * One device, read by a person or by the device holding its credential. + * + * The definition alone: a device compares what it reads with what it is + * showing, and a field that changes on every request would rebuild its + * screen each time. Declared after the pairing routes and ``/pending``, so + * neither is mistaken for a device of that name. + * @param data The data for the request. + * @param data.deviceId + * @returns DeviceDef Successful Response + * @throws ApiError + */ + public static readDevice(data: DevicesReadDeviceData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/devices/{device_id}', + path: { + device_id: data.deviceId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Save Device + * Save a device. Takes effect on its next read. + * + * The body carries the ``version`` it was based on, and a save someone else + * has moved past is refused with 409. + * @param data The data for the request. + * @param data.deviceId + * @param data.requestBody + * @returns DeviceDef Successful Response + * @throws ApiError + */ + public static saveDevice(data: DevicesSaveDeviceData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/devices/{device_id}', + path: { + device_id: data.deviceId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Device + * Remove the device and refuse its credential, wherever it was minted here. + * @param data The data for the request. + * @param data.deviceId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteDevice(data: DevicesDeleteDeviceData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/devices/{device_id}', + path: { + device_id: data.deviceId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Approve Pairing + * Say which device the one showing this code, or making this request, is. + * @param data The data for the request. + * @param data.deviceId + * @param data.requestBody + * @returns Message Successful Response + * @throws ApiError + */ + public static approvePairing(data: DevicesApprovePairingData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/devices/{device_id}/pair', + path: { + device_id: data.deviceId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Unpair Device + * Stop honouring this device's credential, and keep the device. + * + * The screen hanging there is refused on its next request and goes back to + * showing a pairing code, while its dashboards and their arrangement stay. + * + * A credential the portal minted for a remote screen carries no nonce, so + * this does not reach it. That one is revoked at the hub. + * @param data The data for the request. + * @param data.deviceId + * @returns Message Successful Response + * @throws ApiError + */ + public static unpairDevice(data: DevicesUnpairDeviceData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/devices/{device_id}/unpair', + path: { + device_id: data.deviceId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + export class FlavorsService { /** * Read Flavors @@ -2106,205 +2359,6 @@ export class ObservabilityService { } } -export class PanelsService { - /** - * Read Panels - * Every panel, what each one shows, and where to point a device. - * @returns PanelsPublic 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 along with its assignment. Re-pairing one screen - * and keeping the assignment is ``/{panel_id}/unpair`` below. - * - * The body carries the ``version`` it was based on, and a save someone else - * has moved past is refused with 409, as a dashboard's is. Two clients each - * writing back the whole list would otherwise drop each other's panels, and - * a dropped panel revokes its screen at once. - * @param data The data for the request. - * @param data.requestBody - * @returns PanelsPublic 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 an entry - * that expires ten minutes later. Reachable from the internet when this - * instance is enrolled with a portal, which is what the cap and the - * portal's own per-address limits are between. - * @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' - } - }); - } - - /** - * Pending Device - * What is waiting on this code, before anyone says what it is. - * - * Approving a code adopts whatever is holding it, so it is worth seeing that - * it looks like the screen you just hung. - * @param data The data for the request. - * @param data.code - * @returns PendingDevice Successful Response - * @throws ApiError - */ - public static pendingDevice(data: PanelsPendingDeviceData): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v1/panels/pair/{code}/device', - path: { - code: data.code - }, - errors: { - 422: 'Validation Error' - } - }); - } - - /** - * Approve Pairing - * Say which panel the device showing this code is. - * - * A credential minted here names the approver, so what the panel does stays - * attributable to a person rather than to nobody. One minted by the portal — - * for a device that reached this instance only through it — names the - * account this instance was enrolled with instead, since that is the one - * every portal-borne request already acts as. - * @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' - } - }); - } - - /** - * Unpair Panel - * Stop honouring this panel's credential, and keep the panel. - * - * Bumping the nonce refuses the screen hanging there on its next request, so - * it goes back to showing a pairing code — while the panel, the dashboards it - * was assigned and their arrangement stay exactly as they were. Deleting the - * panel is still what throws all three away together. - * - * A credential the portal minted for a remote screen carries no nonce, so - * this does not reach it; that one is revoked at the hub. - * @param data The data for the request. - * @param data.panelId - * @returns Message Successful Response - * @throws ApiError - */ - public static unpairPanel(data: PanelsUnpairPanelData): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/api/v1/panels/{panel_id}/unpair', - path: { - panel_id: data.panelId - }, - 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 1e625bc..e5a9035 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -229,6 +229,67 @@ export type DeadLetter = { reason: string; }; +export type DeviceCreate = { + title?: string; +}; + +/** + * One device, and what it shows. + */ +export type DeviceDef = { + id: string; + title?: string; + dashboards?: Array<(string)>; + touch?: boolean; + io?: Array; + version?: number; +}; + +/** + * One piece of the device's own hardware, wired to a message. + * + * A wall panel is a node as well as a screen: a button on its GPIO, a + * microphone, a speaker. Which ports exist is the device's business, so the + * name is free text and ``config`` is passed through to it unread. + */ +export type DevicePort = { + port: string; + message: string; + direction: 'in' | 'out'; + config?: { + [key: string]: unknown; + }; +}; + +export type direction = 'in' | 'out'; + +/** + * A device as the Devices tab lists it, with whether it is there. + */ +export type DevicePublic = { + id: string; + title?: string; + dashboards?: Array<(string)>; + touch?: boolean; + io?: Array; + version?: number; + online?: boolean; + last_seen?: (string | null); +}; + +/** + * The devices, and the address a device should be pointed at. + * + * The address is the server's own, because the browser's origin is not a + * reliable answer: an admin working through the portal is on the portal's + * origin, and this one is for a screen on this network. + */ +export type DevicesPublic = { + data?: Array; + count?: number; + frontend_host?: string; +}; + /** * Serializable payload types. * @@ -982,8 +1043,21 @@ export type OAuthClientRegister = { token_endpoint_auth_method?: (string | null); }; +/** + * What a device may say about itself when it asks for a code. + */ +export type PairHello = { + name?: string; + board?: string; + firmware?: string; +}; + +/** + * Adopt the device showing ``code``, or the pending ``request``. + */ export type PairRequest = { - code: string; + code?: string; + request?: string; }; /** @@ -996,74 +1070,26 @@ export type PairStarted = { }; /** - * Nothing yet, or the credential and the panel it is for. + * Nothing yet, or the credential and the device it is for. */ export type PairStatus = { access_token?: (string | null); + device?: (string | null); panel?: (string | null); }; -/** - * One device, and what it shows. - */ -export type PanelDef = { - id: string; - title?: string; - dashboards?: Array<(string)>; - touch?: boolean; - io?: Array; - nonce?: number; -}; - -/** - * One piece of the device's own hardware, wired to a message. - * - * A wall panel is a node as well as a screen: a button on its GPIO, a - * microphone, a speaker. Which ports exist is the device's business, so the - * name is free text and ``config`` is passed through to it unread. - */ -export type PanelPort = { - port: string; - message: string; - direction: 'in' | 'out'; - config?: { - [key: string]: unknown; - }; -}; - -export type direction = 'in' | 'out'; - -/** - * Every panel this instance knows about. - */ -export type PanelsConfig = { - panels?: Array; - version?: number; -}; - -/** - * The panels, and the address a device should be pointed at. - * - * The address is the server's own, because the browser's origin is not a - * reliable answer to it: an admin working through the portal is on the - * portal's origin, and this one is for a screen on this network. - * - * An instance enrolled with a portal has a second address, built by the - * dialog from what ``/cloud/status`` reports rather than from here — a panel - * is not the thing that knows whether remote access is on. - */ -export type PanelsPublic = { - panels?: Array; - version?: number; - frontend_host?: string; -}; - /** * Who is asking, as far as the request itself says. */ export type PendingDevice = { + request?: string; device: string; + name?: string; + board?: string; + firmware?: string; remote?: boolean; + needs_code?: boolean; + expires_in?: number; }; /** @@ -1266,14 +1292,14 @@ export type RunRequest = { * called, which is all the matching needs. */ export type SearchEntry = { - category: 'flow' | 'node' | 'input' | 'output' | 'dashboard' | 'widget' | 'panel' | 'secret' | 'module' | 'worker' | 'alert'; + category: 'flow' | 'node' | 'input' | 'output' | 'dashboard' | 'widget' | 'device' | 'secret' | 'module' | 'worker' | 'alert'; name: string; title?: string; parent?: string; kind?: string; }; -export type category = 'flow' | 'node' | 'input' | 'output' | 'dashboard' | 'widget' | 'panel' | 'secret' | 'module' | 'worker' | 'alert'; +export type category = 'flow' | 'node' | 'input' | 'output' | 'dashboard' | 'widget' | 'device' | 'secret' | 'module' | 'worker' | 'alert'; export type SecretNames = { data: Array<(string)>; @@ -1778,6 +1804,68 @@ export type DashboardsRenameDashboardData = { export type DashboardsRenameDashboardResponse = (DashboardDef_Output); +export type DevicesStartPairingData = { + requestBody?: (PairHello | null); +}; + +export type DevicesStartPairingResponse = (PairStarted); + +export type DevicesPollPairingData = { + code: string; + secret?: string; +}; + +export type DevicesPollPairingResponse = (PairStatus); + +export type DevicesReadDevicesResponse = (DevicesPublic); + +export type DevicesReadPendingResponse = (Array); + +export type DevicesPendingDeviceData = { + code: string; +}; + +export type DevicesPendingDeviceResponse = (PendingDevice); + +export type DevicesCreateDeviceData = { + deviceId: string; + requestBody?: (DeviceCreate | null); +}; + +export type DevicesCreateDeviceResponse = (DeviceDef); + +export type DevicesReadDeviceData = { + deviceId: string; +}; + +export type DevicesReadDeviceResponse = (DeviceDef); + +export type DevicesSaveDeviceData = { + deviceId: string; + requestBody: DeviceDef; +}; + +export type DevicesSaveDeviceResponse = (DeviceDef); + +export type DevicesDeleteDeviceData = { + deviceId: string; +}; + +export type DevicesDeleteDeviceResponse = (Message); + +export type DevicesApprovePairingData = { + deviceId: string; + requestBody: PairRequest; +}; + +export type DevicesApprovePairingResponse = (Message); + +export type DevicesUnpairDeviceData = { + deviceId: string; +}; + +export type DevicesUnpairDeviceResponse = (Message); + export type FlavorsReadFlavorsResponse = (FlavorsPublic); export type FlavorsCreateFlavorData = { @@ -2108,48 +2196,6 @@ export type ObservabilityReadDeadLettersResponse = (Array); export type ObservabilityFlushMetricsResponse = (Message); -export type PanelsReadPanelsResponse = (PanelsPublic); - -export type PanelsSavePanelsData = { - requestBody: PanelsConfig; -}; - -export type PanelsSavePanelsResponse = (PanelsPublic); - -export type PanelsStartPairingResponse = (PairStarted); - -export type PanelsPollPairingData = { - code: string; - secret?: string; -}; - -export type PanelsPollPairingResponse = (PairStatus); - -export type PanelsPendingDeviceData = { - code: string; -}; - -export type PanelsPendingDeviceResponse = (PendingDevice); - -export type PanelsApprovePairingData = { - panelId: string; - requestBody: PairRequest; -}; - -export type PanelsApprovePairingResponse = (Message); - -export type PanelsUnpairPanelData = { - panelId: string; -}; - -export type PanelsUnpairPanelResponse = (Message); - -export type PanelsReadPanelData = { - panelId: string; -}; - -export type PanelsReadPanelResponse = (PanelDef); - export type PrivateCreateUserData = { requestBody: PrivateUserCreate; }; diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx index 0f7bbc7..e056b7c 100644 --- a/frontend/src/components/Common/GlobalSearch.tsx +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -51,7 +51,7 @@ const GROUPS: { { category: "output", label: "Outputs", icon: LogOut }, { category: "dashboard", label: "Dashboards", icon: LayoutDashboard }, { category: "widget", label: "Widgets", icon: LayoutGrid }, - { category: "panel", label: "Panels", icon: MonitorSmartphone }, + { category: "device", label: "Devices", icon: MonitorSmartphone }, { category: "secret", label: "Secrets", icon: KeyRound }, { category: "module", label: "Modules", icon: Package }, { category: "worker", label: "Workers", icon: Server }, @@ -179,13 +179,8 @@ export function GlobalSearch({ to: "/dashboards/$name", params: { name: entry.parent ?? "" }, }) - // Panels are managed in a dialog on the dashboards screen, which opens - // itself when the address says so. - case "panel": - return navigate({ - to: "/dashboards", - search: { panels: true, panel: entry.name }, - }) + case "device": + return navigate({ to: "/devices/$id", params: { id: entry.name } }) case "secret": return navigate({ to: "/secrets" }) case "module": diff --git a/frontend/src/components/Common/OverviewCard.tsx b/frontend/src/components/Common/OverviewCard.tsx index 0fdad82..f2af316 100644 --- a/frontend/src/components/Common/OverviewCard.tsx +++ b/frontend/src/components/Common/OverviewCard.tsx @@ -47,7 +47,7 @@ export function useSelection(names: string[]) { } /** - * One entry of the flows or dashboards overview. + * One entry of the flows, dashboards or devices overview. * * Stays a link in both modes rather than swapping element: while selecting it * carries `role="checkbox"` instead, which is what it now behaves as. Ctrl or @@ -60,6 +60,7 @@ export function OverviewCard({ title, detail, draft, + online, selecting, selected, onToggle, @@ -71,6 +72,8 @@ export function OverviewCard({ detail: ReactNode /** Has unpublished changes. */ draft?: boolean + /** Connected right now, for a device. */ + online?: boolean selecting: boolean selected: boolean onToggle: () => void @@ -137,6 +140,11 @@ export function OverviewCard({ Unpublished changes ) : null} + {online ? ( + + Online + + ) : null} {detail} diff --git a/frontend/src/components/Common/OverviewToolbar.tsx b/frontend/src/components/Common/OverviewToolbar.tsx index a5c64b1..9bfff7f 100644 --- a/frontend/src/components/Common/OverviewToolbar.tsx +++ b/frontend/src/components/Common/OverviewToolbar.tsx @@ -43,8 +43,8 @@ export function OverviewToolbar({ searchTestId, createLabel, createTestId, - draftCount, - publishing, + draftCount = 0, + publishing = false, onPublishAll, selectedCount = 0, onDeleteSelected, @@ -59,9 +59,10 @@ export function OverviewToolbar({ createLabel: string createTestId: string /** How many of the listed documents have unpublished changes. */ - draftCount: number - publishing: boolean - onPublishAll: () => void + draftCount?: number + publishing?: boolean + /** Left out for a list of things that have no drafts, such as devices. */ + onPublishAll?: () => void /** How many entries are picked; above zero the list is in selection mode. */ selectedCount?: number onDeleteSelected?: () => void @@ -125,7 +126,7 @@ export function OverviewToolbar({ )} - {selectedCount > 0 ? ( + {selectedCount > 0 && onDuplicateSelected ? ( - - - - {draftCount === 0 - ? "Nothing unpublished" - : `Publish ${draftCount} unpublished change${draftCount === 1 ? "" : "s"}`} - - + + + + + + {draftCount === 0 + ? "Nothing unpublished" + : `Publish ${draftCount} unpublished change${draftCount === 1 ? "" : "s"}`} + + + ) : null} ) } diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 39a5796..983b3e7 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -31,6 +31,7 @@ import { DockSeparator, PublishButton, } from "@/components/Common/Dock" +import { devicesQueryOptions } from "@/components/Devices/queries" import { CanvasTitle } from "@/components/Flow/CanvasTitle" import { Button } from "@/components/ui/button" import { @@ -68,7 +69,6 @@ import { LockNotice } from "./PanelSurface" import { DashboardPanel, WidgetPanel } from "./panels" import { dashboardKeys, - panelsQueryOptions, useDiscardDashboardDraft, usePublishDashboard, useSaveDashboard, @@ -580,16 +580,16 @@ export function DashboardEditor({ ), ) - // A dashboard hanging on a panel beside others is drawn with a rail on it, + // A dashboard hanging on a device beside others is drawn with a rail on 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()) + // ponytail: the first such device wins — the others differ only in scale. + const { data: devices } = useQuery(devicesQueryOptions()) const railDashboards = - (panels?.panels ?? []).find( - (panel) => - (panel.dashboards ?? []).length > 1 && - (panel.dashboards ?? []).includes(draft.name), + (devices?.data ?? []).find( + (device) => + (device.dashboards ?? []).length > 1 && + (device.dashboards ?? []).includes(draft.name), )?.dashboards ?? null // A round face has no room for one: a panel swipes between those instead. const hasRail = Boolean(railDashboards) && !stacked && !round diff --git a/frontend/src/components/Dashboard/PanelsDialog.tsx b/frontend/src/components/Dashboard/PanelsDialog.tsx deleted file mode 100644 index dc876b1..0000000 --- a/frontend/src/components/Dashboard/PanelsDialog.tsx +++ /dev/null @@ -1,783 +0,0 @@ -import { useMutation, useQuery } from "@tanstack/react-query" -import { Plus, Trash2, X } from "lucide-react" -import { useEffect, useState } from "react" - -import { - type ApiError, - CloudService, - type PanelDef, - type PanelPort, - type PanelsConfig, - PanelsService, -} from "@/client" -import { initials, resolveIcon } from "@/components/Dashboard/icons" -import { MessagePicker } from "@/components/Dashboard/panels" -import { - dashboardsQueryOptions, - panelsQueryOptions, - useSavePanels, - useUnpairPanel, -} from "@/components/Dashboard/queries" -import { qualifiedProblem } from "@/components/Flow/deriveEdges" -import { JsonField } from "@/components/Flow/JsonField" -import { InfoTip, PanelSection } from "@/components/Flow/SidePanel" -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 { Segmented } from "@/components/ui/segmented" -import { Separator } from "@/components/ui/separator" -import { Switch } from "@/components/ui/switch" -import useAuth from "@/hooks/useAuth" -import useCustomToast from "@/hooks/useCustomToast" -import { cn } from "@/lib/utils" -import { handleError } from "@/utils" - -/** As many characters as a device puts on the wall. */ -const CODE_LENGTH = 6 - -/** 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, "_") - -/** Port names the panel firmware knows; `gpio.` takes a pin number. */ -// ponytail: free-text ports, no device capability report; add PUT /panels/{id}/device when a second device kind exists -const PORT_SUGGESTIONS = ["gpio.", "backlight", "touch", "mic", "speaker"] - -const DIRECTIONS = [ - ["in", "In"], - ["out", "Out"], -] as const - -/** What the server accepts as a port name. */ -const portOf = (value: string) => - value.toLowerCase().replace(/[^a-z0-9_.]/g, "") - -/** A new port the server will take: a name, and a message named with its flow. */ -const ready = (port: PanelPort) => - Boolean(port.port) && qualifiedProblem(port.message) === null - -const objectOnly = (value: unknown) => - value && typeof value === "object" && !Array.isArray(value) - ? null - : "Config is a JSON object" - -/** - * 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({ focus }: { focus?: string } = {}) { - const { data: config } = useQuery(panelsQueryOptions()) - const { data: dashboards } = useQuery(dashboardsQueryOptions()) - const { user } = useAuth() - // Where a screen that cannot reach this instance pairs instead. The - // issuer is the portal as a browser reaches it, which is not always the - // address this machine dialled — enrolment may have named a container. - const { data: cloud } = useQuery({ - queryKey: ["cloud", "status"], - queryFn: async () => - (await CloudService.readStatus()) as { - enrolled: boolean - issuer: string | null - instance_id: string | null - }, - }) - const remoteHost = - cloud?.enrolled && cloud.issuer && cloud.instance_id - ? `${cloud.issuer.replace(/\/$/, "")}/i/${cloud.instance_id}` - : "" - const save = useSavePanels() - const { showErrorToast } = useCustomToast() - // Reading panels is any account's; every change to them is a superuser's. - const canEdit = Boolean(user?.is_superuser) - const [name, setName] = useState("") - // A port being added, per panel, until it is ready to save. Held here rather - // than in its row: the rows unmount when the dialog closes and this does - // not, so a port still missing its message is there again on reopening. - const [drafts, setDrafts] = useState>({}) - const setDraft = (id: string, draft: PanelPort | null) => - setDrafts((all) => { - const next = { ...all } - if (draft) next[id] = draft - else delete next[id] - return next - }) - - const panels = config?.panels ?? [] - const known = dashboards?.data ?? [] - // The instance's own address, not this browser's: administering through - // the portal puts the page on the portal's origin, and a screen cannot be - // sent there — it has no portal session and could not hold a panel - // credential if it had one. - const host = config?.frontend_host ?? "" - - const write = (next: PanelsConfig) => - save.mutate( - { ...next, version: config?.version }, - { - onError: (error) => - (error as ApiError).status === 409 - ? showErrorToast( - "Someone else changed the panels first. Their version is shown now, so make the change again.", - ) - : handleError.call(showErrorToast, error as ApiError), - }, - ) - - const replace = (id: string, panel: PanelDef) => - write({ panels: panels.map((p) => (p.id === id ? panel : p)) }) - - // Closing saves every new port that is ready, in one write. Its port field - // only commits on blur, and closing does not always blur it. - const saveDrafts = () => { - const done = Object.keys(drafts).filter((id) => ready(drafts[id])) - if (!done.length) return - for (const id of done) setDraft(id, null) - write({ - panels: panels.map((panel) => - done.includes(panel.id) - ? { ...panel, io: [...(panel.io ?? []), drafts[panel.id]] } - : panel, - ), - }) - } - - const newId = slugify(name) - const taken = panels.some((panel) => panel.id === newId) - - // Arriving about one panel, from its port on a flow canvas, lands on it. - const focusLoaded = Boolean(config) && Boolean(focus) - useEffect(() => { - if (!focusLoaded) return - document - .querySelector(`[data-testid="panel-${focus}"]`) - ?.scrollIntoView({ block: "start" }) - }, [focusLoaded, focus]) - - return ( - { - event.preventDefault() - const content = event.currentTarget as HTMLElement - content.focus() - }} - // Escape closes without a blur, and the fields here save on blur. - onEscapeKeyDown={() => (document.activeElement as HTMLElement).blur()} - onCloseAutoFocus={saveDrafts} - > - - Panels - - A panel is one screen and the dashboards it shows. Point the device at - a link and it asks for a code you enter here. - - - -
- {panels.length === 0 ? ( -

- No panels yet. Add one below. -

- ) : null} - - {panels.map((panel) => ( - setDraft(panel.id, draft)} - host={host} - remoteHost={remoteHost} - canEdit={canEdit} - dashboards={known.map((dashboard) => ({ - name: dashboard.name, - title: dashboard.title || dashboard.name, - icon: dashboard.icon ?? "", - }))} - onChange={(next) => replace(panel.id, next)} - onRemove={() => - write({ panels: panels.filter((p) => p.id !== panel.id) }) - } - /> - ))} - - {canEdit ? ( - <> - - -
-
{ - 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} - {/* The name becomes the panel's address and stays its address, - while the title can change any time. */} - {newId && host && !taken ? ( -

- Opens at {host}/panel/{newId} -

- ) : null} -
- - ) : null} -
-
- ) -} - -function PanelRow({ - panel, - focused, - draft, - onDraft, - host, - remoteHost, - canEdit, - dashboards, - onChange, - onRemove, -}: { - panel: PanelDef - /** The one a link arrived about, marked so it is found at a glance. */ - focused: boolean - /** A port being added to it, held by the dialog. */ - draft: PanelPort | null - onDraft: (draft: PanelPort | null) => void - /** Where this instance answers, as it knows itself. */ - host: string - /** Where the portal serves this instance, when it is enrolled. */ - remoteHost: string - /** - * Whether this account may change anything here. Every control below saves - * through the same superuser-only PUT, so a reader gets the panel and its - * links — worth seeing — with the writes turned off rather than a 403. - */ - canEdit: boolean - dashboards: { name: string; title: string; icon: string }[] - onChange: (next: PanelDef) => void - onRemove: () => void -}) { - const { showSuccessToast, showErrorToast } = useCustomToast() - const [code, setCode] = useState("") - // Unpairing cannot be undone from here — the screen has to be at hand to - // read out a new code — so the button asks a second time before it fires. - // ponytail: a two-step button rather than a dialog, since this one already - // lives inside a dialog. - const [confirmUnpair, setConfirmUnpair] = useState(false) - // Removing the panel throws away more than unpairing does, so it asks the - // same way — the icon turns destructive and only the second press fires. - const [confirmRemove, setConfirmRemove] = useState(false) - const unpair = useUnpairPanel(panel.id) - const assigned = panel.dashboards ?? [] - const typed = code.trim().toUpperCase() - - // Approving a code adopts whatever is holding it, so say what that is while - // there is still time to stop. - const { data: waiting } = useQuery({ - queryKey: ["pending-device", typed], - queryFn: () => PanelsService.pendingDevice({ code: typed }), - enabled: canEdit && typed.length === CODE_LENGTH, - retry: false, - }) - - const pair = useMutation({ - mutationFn: () => - PanelsService.approvePairing({ - panelId: panel.id, - requestBody: { code: typed }, - }), - 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 = host ? `${host}/panel/${panel.id}` : "" - const remoteLink = remoteHost ? `${remoteHost}/panel` : "" - // The trash icon carries no text of its own, so the confirm step says what - // the next press does through the label a reader or a hover gets. - const removeLabel = confirmRemove - ? `Confirm: remove ${panel.id}` - : `Remove ${panel.id}` - - return ( -
-
- { - if (event.key === "Enter") event.currentTarget.blur() - }} - onBlur={(event) => { - const title = event.currentTarget.value.trim() - event.currentTarget.value = title - if (title !== panel.title) onChange({ ...panel, title }) - }} - /> - - {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}` - // What this dashboard draws on the rail. Shown rather than set: the - // icon belongs to the document, and this dialog has no draft to put - // a change into. - const Glyph = resolveIcon(dashboard.icon) - return ( - - ) - })} -
- )} - - - -
- event.currentTarget.select()} - /> - {remoteLink ? ( -
- event.currentTarget.select()} - /> -
- ) : null} - {canEdit ? ( - <> -
{ - event.preventDefault() - if (typed) pair.mutate() - }} - > - setCode(event.target.value.toUpperCase())} - /> - -
- {typed.length === CODE_LENGTH ? ( -

- {waiting - ? `${waiting.device}${waiting.remote ? " · via portal" : ""}` - : "No device is waiting on that code."} -

- ) : null} - -
- - - Sends the screen hanging here back to a pairing code, keeping - the panel, its dashboards and their arrangement. Removing the - panel throws all three away instead. A screen paired through the - portal holds a credential this does not reach; revoke that one - there. - -
- - ) : null} -
-
- ) -} - -/** - * The device's own inputs and outputs, each wired to a message. - * - * Every change saves as it is made, like the rest of the dialog. A new port - * is held by the dialog until it has a name and a qualified message, since - * the server refuses a port without either, and closing the dialog saves one - * that has both. - */ -function PanelIo({ - panel, - canEdit, - draft, - onDraft, - onChange, -}: { - panel: PanelDef - canEdit: boolean - draft: PanelPort | null - onDraft: (draft: PanelPort | null) => void - onChange: (next: PanelDef) => void -}) { - const io = panel.io ?? [] - const listId = `ports-${panel.id}` - - const write = (next: PanelPort[]) => onChange({ ...panel, io: next }) - const keepDraft = (next: PanelPort) => { - if (!ready(next)) return onDraft(next) - onDraft(null) - write([...io, next]) - } - - return ( - - - {PORT_SUGGESTIONS.map((port) => ( - - {/* `disabled` on a fieldset reaches every control inside it. */} -
- {io.length === 0 && !draft ? ( -

No ports yet.

- ) : null} - {io.map((row, index) => ( - - write(io.map((one, at) => (at === index ? next : one))) - } - onRemove={() => write(io.filter((_, at) => at !== index))} - /> - ))} - {draft ? ( - onDraft({ ...draft, port })} - onRemove={() => onDraft(null)} - /> - ) : null} - {canEdit && !draft ? ( - - ) : null} -
-
- ) -} - -function IoRow({ - row, - listId, - testId, - onChange, - onType, - onRemove, -}: { - row: PanelPort - /** The datalist of port names to suggest. */ - listId: string - testId: string - onChange: (next: PanelPort) => void - /** - * A new port's name as it is typed, so closing the dialog before the field - * blurs still finds it. A saved port commits on blur instead. - */ - onType?: (port: string) => void - onRemove: () => void -}) { - const name = row.port || "new port" - const problem = row.message ? qualifiedProblem(row.message) : null - return ( -
-
- {onType ? ( - onType(portOf(event.currentTarget.value))} - onBlur={() => onChange(row)} - /> - ) : ( - { - const port = portOf(event.currentTarget.value) - event.currentTarget.value = port || row.port - if (port && port !== row.port) onChange({ ...row, port }) - }} - /> - )} -
- - onChange({ - ...row, - direction: direction as PanelPort["direction"], - }) - } - /> -
- -
-
-
- message.writable !== false - : undefined - } - onPick={(message) => onChange({ ...row, message })} - /> -
- - onChange({ ...row, config: config as PanelPort["config"] }) - } - /> -
- {problem ?

{problem}

: null} -
- ) -} diff --git a/frontend/src/components/Dashboard/queries.ts b/frontend/src/components/Dashboard/queries.ts index 4cc77bb..82950b3 100644 --- a/frontend/src/components/Dashboard/queries.ts +++ b/frontend/src/components/Dashboard/queries.ts @@ -1,12 +1,9 @@ import { useMutation, useQueryClient } from "@tanstack/react-query" import { - ApiError, type DashboardDef_Input, DashboardsService, MessagesService, - type PanelsConfig, - PanelsService, } from "@/client" export const dashboardKeys = { @@ -34,66 +31,6 @@ 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, carrying the version they were read at. Superuser-only - * on the server. - * - * A 409 means someone else saved first, so the list is read again and the - * next change is based on theirs. - */ -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 }) - }, - onError: (error) => { - if (error instanceof ApiError && error.status === 409) - queryClient.invalidateQueries({ queryKey: panelKeys.all }) - }, - }) -} - -/** - * Stop honouring one panel's credential, and keep the panel. - * - * The screen goes back to showing a pairing code while its dashboards and their - * arrangement stay — deleting the panel is what throws all three away. - * Superuser-only on the server. - */ -export function useUnpairPanel(id: string) { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: () => PanelsService.unpairPanel({ panelId: id }), - onSuccess: () => 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/Devices/AddDeviceDialog.tsx b/frontend/src/components/Devices/AddDeviceDialog.tsx new file mode 100644 index 0000000..23ac930 --- /dev/null +++ b/frontend/src/components/Devices/AddDeviceDialog.tsx @@ -0,0 +1,107 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" +import { useState } from "react" + +import { DevicesService } from "@/client" +import { Button } from "@/components/ui/button" +import { + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" +import { deviceKeys } from "./queries" + +/** 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, "_") + .replace(/^_+|_+$/g, "") + +/** + * Name a new device and open it. The name becomes its address + * (`/panel/{name}`) and stays that, while the title can change any time. + */ +export function AddDeviceDialog({ + taken, + host, +}: { + /** Ids already in use, refused before the request is. */ + taken: string[] + /** Where this instance answers, to show the address the name gives. */ + host: string +}) { + const queryClient = useQueryClient() + const navigate = useNavigate() + const { showErrorToast } = useCustomToast() + const [name, setName] = useState("") + const id = slugify(name) + const exists = taken.includes(id) + + const create = useMutation({ + mutationFn: () => + DevicesService.createDevice({ + deviceId: id, + requestBody: { title: name.trim() }, + }), + onSuccess: (created) => { + queryClient.invalidateQueries({ queryKey: deviceKeys.all }) + setName("") + navigate({ to: "/devices/$id", params: { id: created.id } }) + }, + onError: handleError.bind(showErrorToast), + }) + + return ( + +
{ + event.preventDefault() + if (id && !exists) create.mutate() + }} + > + + Add device + + Name it after where it hangs. You pair the device itself next. + + +
+ setName(event.target.value)} + /> + {exists ? ( +

+ There is already a device called {id}. +

+ ) : id && host ? ( +

+ Opens at {host}/panel/{id} +

+ ) : null} +
+ + + +
+
+ ) +} diff --git a/frontend/src/components/Devices/DeviceDetail.tsx b/frontend/src/components/Devices/DeviceDetail.tsx new file mode 100644 index 0000000..d864bf1 --- /dev/null +++ b/frontend/src/components/Devices/DeviceDetail.tsx @@ -0,0 +1,747 @@ +import { useQuery } from "@tanstack/react-query" +import { Link } from "@tanstack/react-router" +import { ArrowDown, ArrowUp, Plus, X } from "lucide-react" +import { useState } from "react" + +import { + type ApiError, + CloudService, + type DeviceDef, + type DevicePort, + type DevicePublic, + DevicesService, +} from "@/client" +import { initials, resolveIcon } from "@/components/Dashboard/icons" +import { MessagePicker } from "@/components/Dashboard/panels" +import { dashboardsQueryOptions } from "@/components/Dashboard/queries" +import { qualifiedProblem } from "@/components/Flow/deriveEdges" +import { JsonField } from "@/components/Flow/JsonField" +import { InfoTip, PanelSection } from "@/components/Flow/SidePanel" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Segmented } from "@/components/ui/segmented" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Switch } from "@/components/ui/switch" +import useAuth from "@/hooks/useAuth" +import useCustomToast from "@/hooks/useCustomToast" +import { ago, cn } from "@/lib/utils" +import { handleError } from "@/utils" +import { + devicesQueryOptions, + pendingQueryOptions, + usePairDevice, + useSaveDevice, + useUnpairDevice, +} from "./queries" + +const CARD = "rounded-lg border border-border bg-card p-4 shadow-e1" + +/** As many characters as a device puts on the wall. */ +const CODE_LENGTH = 6 + +/** Port names the panel firmware knows; `gpio.` takes a pin number. */ +// ponytail: free-text ports until a device reports what it has (PUT /devices/{id}/report) +const PORT_SUGGESTIONS = ["gpio.", "backlight", "touch", "mic", "speaker"] + +const DIRECTIONS = [ + ["in", "In"], + ["out", "Out"], +] as const + +/** What the server accepts as a port name. */ +const portOf = (value: string) => + value.toLowerCase().replace(/[^a-z0-9_.]/g, "") + +/** A new port the server will take: a name, and a message named with its flow. */ +const ready = (port: DevicePort) => + Boolean(port.port) && qualifiedProblem(port.message) === null + +const objectOnly = (value: unknown) => + value && typeof value === "object" && !Array.isArray(value) + ? null + : "Config is a JSON object" + +/** "Online", or when it was last heard from. */ +export function presenceText(device: DevicePublic) { + if (device.online) return "Online" + return device.last_seen ? `Seen ${ago(device.last_seen)}` : "Not seen yet" +} + +/** + * One device: whether it is there, how it pairs, what it shows and its own + * hardware. + * + * Every change saves as it is made, against the version it was read at, so + * there is no form to submit and no draft to publish: a device is a thing on + * the wall rather than a document. + */ +export function DeviceDetail({ id }: { id: string }) { + const { data, isPending } = useQuery(devicesQueryOptions()) + const { user } = useAuth() + const save = useSaveDevice(id) + const { showErrorToast } = useCustomToast() + // Reading devices is any account's, every change to them a superuser's. + const canEdit = Boolean(user?.is_superuser) + + if (isPending) return + const device = data?.data?.find((one) => one.id === id) + if (!device) { + return ( +
+ + Devices + +

No device named {id}.

+
+ ) + } + + const write = (changes: Partial) => + save.mutate(changes, { + onError: (error) => + (error as ApiError).status === 409 + ? showErrorToast( + "Someone else changed this device first. Their version is shown now, so make the change again.", + ) + : handleError.call(showErrorToast, error as ApiError), + }) + + return ( +
+
+ + Devices + +

+ {device.title || device.id} +

+ + {presenceText(device)} + +
+ +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ ) +} + +function Basics({ + device, + canEdit, + onChange, +}: { + device: DeviceDef + canEdit: boolean + onChange: (changes: Partial) => void +}) { + return ( + +
+ { + if (event.key === "Enter") event.currentTarget.blur() + }} + onBlur={(event) => { + const title = event.currentTarget.value.trim() + event.currentTarget.value = title + if (title !== device.title) onChange({ title }) + }} + /> + + {device.id} + +
+ +
+ ) +} + +/** Which dashboards the device shows, in the order its rail carries them. */ +function Dashboards({ + device, + canEdit, + onChange, +}: { + device: DeviceDef + canEdit: boolean + onChange: (changes: Partial) => void +}) { + const { data } = useQuery(dashboardsQueryOptions()) + const known = data?.data ?? [] + const assigned = device.dashboards ?? [] + const byName = new Map(known.map((dashboard) => [dashboard.name, dashboard])) + const free = known.filter((dashboard) => !assigned.includes(dashboard.name)) + const set = (dashboards: string[]) => onChange({ dashboards }) + const move = (from: number, by: number) => { + const next = [...assigned] + const [name] = next.splice(from, 1) + next.splice(from + by, 0, name) + set(next) + } + + return ( + + {assigned.length === 0 ? ( +

+ No dashboards yet. The device shows nothing until it has one. +

+ ) : ( +
    + {assigned.map((name, index) => { + const dashboard = byName.get(name) + const title = dashboard?.title || name + // What this dashboard draws on the rail. + const Glyph = resolveIcon(dashboard?.icon ?? "") + return ( +
  1. + + {index + 1} + + + {Glyph ? : initials(title)} + + {dashboard ? ( + + {title} + + ) : ( + // Deleted since: the device skips it. + + {name} + + )} + {canEdit ? ( + + + + + + ) : null} +
  2. + ) + })} +
+ )} + {canEdit && free.length > 0 ? ( + + ) : null} +
+ ) +} + +/** + * How a screen gets its credential: by the code it shows, or picked from the + * devices asking on this network. Plus the addresses to point one at, and the + * way to sign the current one out. + */ +function Pairing({ + id, + host, + canEdit, +}: { + id: string + /** Where this instance answers, as it knows itself. */ + host: string + canEdit: boolean +}) { + const { showSuccessToast, showErrorToast } = useCustomToast() + const [code, setCode] = useState("") + // Unpairing cannot be undone from here, since the screen has to be at hand + // to read out a new code, so the button asks a second time before it fires. + const [confirmUnpair, setConfirmUnpair] = useState(false) + const pair = usePairDevice(id) + const unpair = useUnpairDevice(id) + const typed = code.trim().toUpperCase() + + // Where a screen that cannot reach this instance pairs instead. The issuer + // is the portal as a browser reaches it. + const { data: cloud } = useQuery({ + queryKey: ["cloud", "status"], + queryFn: async () => + (await CloudService.readStatus()) as { + enrolled: boolean + issuer: string | null + instance_id: string | null + }, + }) + const remoteLink = + cloud?.enrolled && cloud.issuer && cloud.instance_id + ? `${cloud.issuer.replace(/\/$/, "")}/i/${cloud.instance_id}/panel` + : "" + // The instance's own address, not this browser's: administering through + // the portal puts the page on the portal's origin, which a screen on this + // network cannot be sent to. + const link = host ? `${host}/panel/${id}` : "" + + const { data: pending } = useQuery({ + ...pendingQueryOptions(), + enabled: canEdit, + }) + // Approving a code adopts whatever holds it, so say what that is while + // there is still time to stop. + const { data: waiting } = useQuery({ + queryKey: ["pending-device", typed], + queryFn: () => DevicesService.pendingDevice({ code: typed }), + enabled: canEdit && typed.length === CODE_LENGTH, + retry: false, + }) + + const paired = { + onSuccess: () => { + setCode("") + showSuccessToast("Paired. The screen switches over within a few seconds.") + }, + onError: (error: Error) => + handleError.call(showErrorToast, error as ApiError), + } + + return ( + +
+ event.currentTarget.select()} + /> + {remoteLink ? ( + event.currentTarget.select()} + /> + ) : null} +
+ + {canEdit ? ( + <> +
{ + event.preventDefault() + if (typed) pair.mutate({ code: typed }, paired) + }} + > + setCode(event.target.value.toUpperCase())} + /> + +
+ {typed.length === CODE_LENGTH ? ( +

+ {waiting + ? `${waiting.name || waiting.device}${waiting.remote ? " · via portal" : ""}` + : "No device is waiting on that code."} +

+ ) : null} + +
+ + {pending?.length + ? "Waiting to be paired" + : "No device is waiting"} + + {(pending ?? []).map((entry) => ( +
+ + + {entry.name || entry.board || "Unnamed device"} + {entry.firmware ? ( + + {" "} + · {entry.firmware} + + ) : null} + + + {entry.device} + + + {entry.needs_code ? ( + + {entry.remote ? "Via portal, " : ""}needs its code + + ) : ( + + )} +
+ ))} +
+ +
+ + + Sends the screen back to a pairing code and keeps the device and + its dashboards. A screen paired through the portal is revoked + there instead. + +
+ + ) : null} +
+ ) +} + +/** + * The device's own inputs and outputs, each wired to a message. + * + * A new port is held here until it has a name and a qualified message, since + * the server refuses a port without either. + */ +function DeviceIo({ + device, + canEdit, + onChange, +}: { + device: DeviceDef + canEdit: boolean + onChange: (changes: Partial) => void +}) { + const [draft, setDraft] = useState(null) + const io = device.io ?? [] + const listId = `ports-${device.id}` + + const write = (io: DevicePort[]) => onChange({ io }) + const keepDraft = (next: DevicePort) => { + if (!ready(next)) return setDraft(next) + setDraft(null) + write([...io, next]) + } + + return ( + + + {PORT_SUGGESTIONS.map((port) => ( + + {/* `disabled` on a fieldset reaches every control inside it. */} +
+ {io.length === 0 && !draft ? ( +

No ports yet.

+ ) : null} + {io.map((row, index) => ( + + write(io.map((one, at) => (at === index ? next : one))) + } + onRemove={() => write(io.filter((_, at) => at !== index))} + /> + ))} + {draft ? ( + setDraft({ ...draft, port })} + onRemove={() => setDraft(null)} + /> + ) : null} + {canEdit && !draft ? ( + + ) : null} +
+
+ ) +} + +function IoRow({ + row, + listId, + testId, + onChange, + onType, + onRemove, +}: { + row: DevicePort + /** The datalist of port names to suggest. */ + listId: string + testId: string + onChange: (next: DevicePort) => void + /** A new port's name as it is typed. A saved port commits on blur instead. */ + onType?: (port: string) => void + onRemove: () => void +}) { + const name = row.port || "new port" + const problem = row.message ? qualifiedProblem(row.message) : null + return ( +
+
+ {onType ? ( + onType(portOf(event.currentTarget.value))} + onBlur={() => onChange(row)} + /> + ) : ( + { + const port = portOf(event.currentTarget.value) + event.currentTarget.value = port || row.port + if (port && port !== row.port) onChange({ ...row, port }) + }} + /> + )} +
+ + onChange({ + ...row, + direction: direction as DevicePort["direction"], + }) + } + /> +
+ +
+
+
+ message.writable !== false + : undefined + } + onPick={(message) => onChange({ ...row, message })} + /> +
+ + onChange({ ...row, config: config as DevicePort["config"] }) + } + /> +
+ {problem ?

{problem}

: null} +
+ ) +} diff --git a/frontend/src/components/Devices/queries.ts b/frontend/src/components/Devices/queries.ts new file mode 100644 index 0000000..0275b2d --- /dev/null +++ b/frontend/src/components/Devices/queries.ts @@ -0,0 +1,108 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" + +import { + type DeviceDef, + type DevicePublic, + type DevicesPublic, + DevicesService, +} from "@/client" + +export const deviceKeys = { + all: ["devices"] as const, + detail: (id: string) => ["devices", id] as const, + pending: ["devices-pending"] as const, +} + +/** Every device, what it shows and whether it is connected. */ +export const devicesQueryOptions = () => ({ + queryKey: deviceKeys.all, + queryFn: () => DevicesService.readDevices(), +}) + +/** + * One device, as the screen hanging on the wall reads it. + * + * A device credential may read exactly this and the dashboards it names, so + * this is the query the `/panel/{id}` route is built on rather than the list. + */ +export const deviceQueryOptions = (id: string) => ({ + queryKey: deviceKeys.detail(id), + queryFn: () => DevicesService.readDevice({ deviceId: id }), +}) + +/** Devices asking for a code right now. Polled: nothing announces a new one. */ +export const pendingQueryOptions = () => ({ + queryKey: deviceKeys.pending, + queryFn: () => DevicesService.readPending(), + refetchInterval: 3000, +}) + +/** + * Save changes to one device. Superuser-only. + * + * A change is shown at once, and the saves of one device go one at a time, + * each laid over the device as the cache holds it when it is sent and + * against the version the save before it left. So a title and a switch + * changed in one gesture neither refuse nor undo each other. Any failure, a + * 409 from someone else's save included, reads the list again. + */ +export function useSaveDevice(id: string) { + const queryClient = useQueryClient() + const stored = () => + queryClient + .getQueryData(deviceKeys.all) + ?.data?.find((one) => one.id === id) + const patch = (changes: Partial) => + queryClient.setQueryData(deviceKeys.all, (all) => + all + ? { + ...all, + data: all.data?.map((one) => + one.id === id ? { ...one, ...changes } : one, + ), + } + : all, + ) + return useMutation({ + scope: { id: `device:${id}` }, + onMutate: (changes: Partial) => patch(changes), + mutationFn: (changes: Partial) => { + const current = stored() + if (!current) throw new Error(`No device named ${id}`) + // Presence is the list's, not part of what is saved. + const { online: _online, last_seen: _seen, ...device } = current + return DevicesService.saveDevice({ + deviceId: id, + requestBody: { ...device, ...changes }, + }) + }, + // The version alone: the cache already shows this change, and a save + // queued behind it may have shown another since. + onSuccess: (saved) => patch({ version: saved.version }), + onError: () => queryClient.invalidateQueries({ queryKey: deviceKeys.all }), + }) +} + +/** + * Adopt the device showing `code`, or the pending `request`. The device + * collects its credential on its next poll. + */ +export function usePairDevice(id: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (body: { code?: string; request?: string }) => + DevicesService.approvePairing({ deviceId: id, requestBody: body }), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: deviceKeys.pending }), + }) +} + +/** Send the screen hanging here back to a pairing code, keeping the device. */ +export function useUnpairDevice(id: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => DevicesService.unpairDevice({ deviceId: id }), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: deviceKeys.all }), + }) +} diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index 68004bd..f9f8e65 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -512,10 +512,10 @@ function FlowEditorInner({ params: { name: (rest ?? "").split(":")[0] }, }) } else if (kind === "panel") { - // A panel has no page of its own, only its row in the Panels dialog. + // A device's own port: its page in the Devices tab. navigate({ - to: "/dashboards", - search: { panels: true, panel: (rest ?? "").split(":")[0] }, + to: "/devices/$id", + params: { id: (rest ?? "").split(":")[0] }, }) } else if (kind === "flow") { navigate({ diff --git a/frontend/src/components/Flow/useFlowSocket.ts b/frontend/src/components/Flow/useFlowSocket.ts index 7f5c364..6d913cf 100644 --- a/frontend/src/components/Flow/useFlowSocket.ts +++ b/frontend/src/components/Flow/useFlowSocket.ts @@ -3,7 +3,8 @@ import { useQueryClient } from "@tanstack/react-query" import { useEffect } from "react" import { ApiError, FlowsService, OpenAPI } from "@/client" -import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries" +import { dashboardKeys } from "@/components/Dashboard/queries" +import { deviceKeys } from "@/components/Devices/queries" import { healthKeys } from "@/components/Health/queries" import { runKeys } from "@/components/Runs/queries" import { connectionStore } from "@/lib/connectionStore" @@ -111,6 +112,13 @@ type FlowEvent = paused?: string[] } | { type: "dashboard_changed"; dashboard?: string; ts?: number } + | { + type: "device_status" + device: string + online: boolean + last_seen?: string | null + ts?: number + } | { type: "run_started" | "run_finished" flow: string @@ -258,7 +266,7 @@ function open(url: string) { for (const queryKey of [ flowKeys.all, dashboardKeys.all, - panelKeys.all, + deviceKeys.all, runKeys.all, healthKeys.all, ]) { @@ -399,7 +407,7 @@ function open(url: string) { client?.invalidateQueries({ queryKey: flowKeys.all }) break case "dashboard_changed": - // Someone published, or changed which dashboards a panel shows. Refetch + // Someone published, or changed which dashboards a device shows. Refetch // rather than reload: a wall screen must not blank or sign in again. // `exact` matters — the list key is a prefix of every detail key, // including the draft an editor may have open. @@ -407,9 +415,14 @@ function open(url: string) { client?.invalidateQueries({ queryKey: message.dashboard ? dashboardKeys.detail(message.dashboard) - : panelKeys.all, + : deviceKeys.all, }) break + case "device_status": + // A device's socket opened or closed: the Devices tab reads presence + // off the list. + client?.invalidateQueries({ queryKey: deviceKeys.all, exact: true }) + break case "run_started": case "run_finished": // One invalidation covers the lot: the list, the flow counts, the run diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index a741a1b..74f1d2b 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -9,6 +9,7 @@ import { KeyRound, LayoutDashboard, LogOut, + MonitorSmartphone, Package, Search, Server, @@ -49,6 +50,8 @@ const baseItems: Item[] = [ { icon: Home, title: "Home", path: "/" }, { icon: Workflow, title: "Flows", path: "/flows" }, { icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" }, + // What hangs on the wall and which of the dashboards it shows. + { icon: MonitorSmartphone, title: "Devices", path: "/devices" }, // What a batch flow leaves behind. Beside the flows rather than under Home: // an experiment log is browsed, not glanced at. { icon: FlaskConical, title: "Runs", path: "/runs" }, diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 44d431b..06af818 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -29,6 +29,8 @@ import { Route as ViewNameRouteImport } from './routes/view.$name' import { Route as CanvasDashboardsNameRouteImport } from './routes/_canvas/dashboards/$name' import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName' import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index' +import { Route as LayoutDevicesIndexRouteImport } from './routes/_layout/devices/index' +import { Route as LayoutDevicesIdRouteImport } from './routes/_layout/devices/$id' import { Route as LayoutFlowsIndexRouteImport } from './routes/_layout/flows/index' import { Route as LayoutRunsIndexRouteImport } from './routes/_layout/runs/index' import { Route as LayoutRunsIdRouteImport } from './routes/_layout/runs/$id' @@ -132,6 +134,16 @@ const LayoutDashboardsIndexRoute = LayoutDashboardsIndexRouteImport.update({ path: '/dashboards/', getParentRoute: () => LayoutRoute, } as any) +const LayoutDevicesIndexRoute = LayoutDevicesIndexRouteImport.update({ + id: '/devices/', + path: '/devices/', + getParentRoute: () => LayoutRoute, +} as any) +const LayoutDevicesIdRoute = LayoutDevicesIdRouteImport.update({ + id: '/devices/$id', + path: '/devices/$id', + getParentRoute: () => LayoutRoute, +} as any) const LayoutFlowsIndexRoute = LayoutFlowsIndexRouteImport.update({ id: '/flows/', path: '/flows/', @@ -171,9 +183,11 @@ export interface FileRoutesByFullPath { '/panel/': typeof PanelIndexRoute '/dashboards/$name': typeof CanvasDashboardsNameRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute + '/devices/$id': typeof LayoutDevicesIdRoute '/runs/$id': typeof LayoutRunsIdRoute '/runs/new': typeof LayoutRunsNewRoute '/dashboards/': typeof LayoutDashboardsIndexRoute + '/devices/': typeof LayoutDevicesIndexRoute '/flows/': typeof LayoutFlowsIndexRoute '/runs/': typeof LayoutRunsIndexRoute } @@ -195,9 +209,11 @@ export interface FileRoutesByTo { '/panel': typeof PanelIndexRoute '/dashboards/$name': typeof CanvasDashboardsNameRoute '/flows/$flowName': typeof CanvasFlowsFlowNameRoute + '/devices/$id': typeof LayoutDevicesIdRoute '/runs/$id': typeof LayoutRunsIdRoute '/runs/new': typeof LayoutRunsNewRoute '/dashboards': typeof LayoutDashboardsIndexRoute + '/devices': typeof LayoutDevicesIndexRoute '/flows': typeof LayoutFlowsIndexRoute '/runs': typeof LayoutRunsIndexRoute } @@ -222,9 +238,11 @@ export interface FileRoutesById { '/panel/': typeof PanelIndexRoute '/_canvas/dashboards/$name': typeof CanvasDashboardsNameRoute '/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute + '/_layout/devices/$id': typeof LayoutDevicesIdRoute '/_layout/runs/$id': typeof LayoutRunsIdRoute '/_layout/runs/new': typeof LayoutRunsNewRoute '/_layout/dashboards/': typeof LayoutDashboardsIndexRoute + '/_layout/devices/': typeof LayoutDevicesIndexRoute '/_layout/flows/': typeof LayoutFlowsIndexRoute '/_layout/runs/': typeof LayoutRunsIndexRoute } @@ -248,9 +266,11 @@ export interface FileRouteTypes { | '/panel/' | '/dashboards/$name' | '/flows/$flowName' + | '/devices/$id' | '/runs/$id' | '/runs/new' | '/dashboards/' + | '/devices/' | '/flows/' | '/runs/' fileRoutesByTo: FileRoutesByTo @@ -272,9 +292,11 @@ export interface FileRouteTypes { | '/panel' | '/dashboards/$name' | '/flows/$flowName' + | '/devices/$id' | '/runs/$id' | '/runs/new' | '/dashboards' + | '/devices' | '/flows' | '/runs' id: @@ -298,9 +320,11 @@ export interface FileRouteTypes { | '/panel/' | '/_canvas/dashboards/$name' | '/_canvas/flows/$flowName' + | '/_layout/devices/$id' | '/_layout/runs/$id' | '/_layout/runs/new' | '/_layout/dashboards/' + | '/_layout/devices/' | '/_layout/flows/' | '/_layout/runs/' fileRoutesById: FileRoutesById @@ -460,6 +484,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutDashboardsIndexRouteImport parentRoute: typeof LayoutRoute } + '/_layout/devices/': { + id: '/_layout/devices/' + path: '/devices' + fullPath: '/devices/' + preLoaderRoute: typeof LayoutDevicesIndexRouteImport + parentRoute: typeof LayoutRoute + } + '/_layout/devices/$id': { + id: '/_layout/devices/$id' + path: '/devices/$id' + fullPath: '/devices/$id' + preLoaderRoute: typeof LayoutDevicesIdRouteImport + parentRoute: typeof LayoutRoute + } '/_layout/flows/': { id: '/_layout/flows/' path: '/flows' @@ -512,9 +550,11 @@ interface LayoutRouteChildren { LayoutSettingsRoute: typeof LayoutSettingsRoute LayoutWorkersRoute: typeof LayoutWorkersRoute LayoutIndexRoute: typeof LayoutIndexRoute + LayoutDevicesIdRoute: typeof LayoutDevicesIdRoute LayoutRunsIdRoute: typeof LayoutRunsIdRoute LayoutRunsNewRoute: typeof LayoutRunsNewRoute LayoutDashboardsIndexRoute: typeof LayoutDashboardsIndexRoute + LayoutDevicesIndexRoute: typeof LayoutDevicesIndexRoute LayoutFlowsIndexRoute: typeof LayoutFlowsIndexRoute LayoutRunsIndexRoute: typeof LayoutRunsIndexRoute } @@ -527,9 +567,11 @@ const LayoutRouteChildren: LayoutRouteChildren = { LayoutSettingsRoute: LayoutSettingsRoute, LayoutWorkersRoute: LayoutWorkersRoute, LayoutIndexRoute: LayoutIndexRoute, + LayoutDevicesIdRoute: LayoutDevicesIdRoute, LayoutRunsIdRoute: LayoutRunsIdRoute, LayoutRunsNewRoute: LayoutRunsNewRoute, LayoutDashboardsIndexRoute: LayoutDashboardsIndexRoute, + LayoutDevicesIndexRoute: LayoutDevicesIndexRoute, LayoutFlowsIndexRoute: LayoutFlowsIndexRoute, LayoutRunsIndexRoute: LayoutRunsIndexRoute, } diff --git a/frontend/src/routes/_layout/dashboards/index.tsx b/frontend/src/routes/_layout/dashboards/index.tsx index 846068a..f45c134 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, useNavigate } from "@tanstack/react-router" -import { LayoutDashboard, MonitorSmartphone } from "lucide-react" +import { LayoutDashboard } from "lucide-react" import { useState } from "react" import { DashboardsService } from "@/client" @@ -12,7 +12,6 @@ import { useDuplicateSelected, usePublishAll, } from "@/components/Common/OverviewToolbar" -import { PanelsDialog } from "@/components/Dashboard/PanelsDialog" import { dashboardKeys, dashboardsQueryOptions, @@ -28,25 +27,11 @@ import { } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Skeleton } from "@/components/ui/skeleton" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" import useCustomToast from "@/hooks/useCustomToast" import { handleError } from "@/utils" -type Search = { panels?: boolean; panel?: string } - export const Route = createFileRoute("/_layout/dashboards/")({ component: Dashboards, - // The panels dialog has no route of its own, so the address is how anything - // else — the global search among them — arrives at it. - validateSearch: (search: Record): Search => ({ - panels: search.panels === true || search.panels === "true" || undefined, - // Which panel to scroll to, for a link that is about one of them. - panel: typeof search.panel === "string" ? search.panel : undefined, - }), }) function Dashboards() { @@ -58,11 +43,6 @@ function Dashboards() { const [search, setSearch] = useState("") const [dialogOpen, setDialogOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) - // Which screens exist is a question with an address, so anything can link to - // it — the global search lands a panel here. - const { panels: panelsOpen, panel: panelFocus } = Route.useSearch() - const setPanelsOpen = (open: boolean) => - navigate({ to: "/dashboards", search: open ? { panels: true } : {} }) const create = useMutation({ mutationFn: (dashboard: string) => @@ -131,12 +111,6 @@ function Dashboards() {

- {/* Its own root rather than a nested one: which screens exist is a - different question from which dashboards do. */} - - - - setDeleteOpen(true)} duplicating={duplicate.isPending} onDuplicateSelected={() => duplicate.mutate(selection.selected)} - > - - - - - Panels - - + />
({ meta: [{ title: `${params.id} - Fluksio` }] }), +}) + +function Device() { + const { id } = Route.useParams() + return +} diff --git a/frontend/src/routes/_layout/devices/index.tsx b/frontend/src/routes/_layout/devices/index.tsx new file mode 100644 index 0000000..62eee6f --- /dev/null +++ b/frontend/src/routes/_layout/devices/index.tsx @@ -0,0 +1,122 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { createFileRoute } from "@tanstack/react-router" +import { MonitorSmartphone } from "lucide-react" +import { useState } from "react" + +import { DevicesService } from "@/client" +import { OverviewCard, useSelection } from "@/components/Common/OverviewCard" +import { + ConfirmDelete, + OverviewToolbar, + useDeleteSelected, +} from "@/components/Common/OverviewToolbar" +import { AddDeviceDialog } from "@/components/Devices/AddDeviceDialog" +import { presenceText } from "@/components/Devices/DeviceDetail" +import { deviceKeys, devicesQueryOptions } from "@/components/Devices/queries" +import { Dialog } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import useAuth from "@/hooks/useAuth" + +export const Route = createFileRoute("/_layout/devices/")({ + component: Devices, + head: () => ({ meta: [{ title: "Devices - Fluksio" }] }), +}) + +function Devices() { + const { data, isPending } = useQuery(devicesQueryOptions()) + const queryClient = useQueryClient() + const { user } = useAuth() + const [search, setSearch] = useState("") + const [addOpen, setAddOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + + const needle = search.trim().toLowerCase() + const all = data?.data ?? [] + const devices = all.filter((device) => + `${device.id} ${device.title ?? ""}`.toLowerCase().includes(needle), + ) + const selection = useSelection(devices.map((device) => device.id)) + const remove = useDeleteSelected( + (id) => DevicesService.deleteDevice({ deviceId: id }), + "device", + () => queryClient.invalidateQueries({ queryKey: deviceKeys.all }), + ) + + return ( +
+
+

Devices

+

+ The screens and boards paired with this instance, and what each shows. +

+
+ + + {/* Adding and removing devices is a superuser's, like pairing them. */} + {user?.is_superuser ? ( + setDeleteOpen(true)} + /> + ) : null} + device.id)} + host={data?.frontend_host ?? ""} + /> + + + { + setDeleteOpen(false) + remove.mutate(selection.selected) + }} + /> + + {isPending ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ) : devices.length === 0 ? ( +

+ {needle + ? "No device matches that." + : "No devices yet. Add one, then pair the screen with it."} +

+ ) : ( +
+ {devices.map((device) => { + const count = (device.dashboards ?? []).length + return ( + selection.toggle(device.id)} + testId="device-card" + /> + ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/routes/panel.$id.tsx b/frontend/src/routes/panel.$id.tsx index eaa3cde..d1d399b 100644 --- a/frontend/src/routes/panel.$id.tsx +++ b/frontend/src/routes/panel.$id.tsx @@ -5,21 +5,20 @@ import { useCallback, useMemo, useState } from "react" import type { Dashboard } from "@/components/Dashboard/DashboardView" import { PanelRail } from "@/components/Dashboard/PanelRail" import { PanelSurface } from "@/components/Dashboard/PanelSurface" -import { - dashboardQueryOptions, - panelQueryOptions, -} from "@/components/Dashboard/queries" +import { dashboardQueryOptions } from "@/components/Dashboard/queries" import { LookProvider, useCanvasRoot, } from "@/components/Dashboard/ui/core/look" +import { deviceQueryOptions } from "@/components/Devices/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. + * What a paired device shows: the dashboards it was assigned. The address + * stays `/panel/{id}`, which is what screens already hanging were pointed at. * * The same full-bleed surface as `/view/{name}`, with a rail down the left * (along the bottom on a phone) when there is more than one to switch between — so a hallway tablet and a @@ -50,7 +49,7 @@ function PanelRoute() { const navigate = Route.useNavigate() useFlowSocket() - const { data: panel } = useQuery(panelQueryOptions(id)) + const { data: panel } = useQuery(deviceQueryOptions(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. diff --git a/frontend/src/routes/panel.index.tsx b/frontend/src/routes/panel.index.tsx index e91add9..92faa12 100644 --- a/frontend/src/routes/panel.index.tsx +++ b/frontend/src/routes/panel.index.tsx @@ -3,7 +3,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router" import { Check, Copy } from "lucide-react" import { useEffect } from "react" -import { ApiError, PanelsService } from "@/client" +import { ApiError, DevicesService } from "@/client" import { Button } from "@/components/ui/button" import { useCopyToClipboard } from "@/hooks/useCopyToClipboard" import { apiToken, appPath, isPortal, openPortalSession } from "@/lib/portal" @@ -42,8 +42,9 @@ function pairedPanel(): string { * * 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. + * code into the device's page in the Devices tab, or picks this screen from + * the ones waiting on their network, and the credential it mints arrives here + * on the next poll. * * Only once, though: a screen that already holds a credential goes straight to * the panel it names, so a reboot comes back to the dashboard rather than @@ -75,7 +76,7 @@ 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(), + mutationFn: () => DevicesService.startPairing(), // A screen hung while the link is down has nobody to tell, so it keeps // asking rather than showing six dots until someone power-cycles it. retry: true, @@ -92,7 +93,7 @@ function PairPanel() { const { data: status, error } = useQuery({ queryKey: ["pairing", started?.code], queryFn: () => - PanelsService.pollPairing({ + DevicesService.pollPairing({ code: started?.code ?? "", secret: started?.secret ?? "", }), @@ -111,9 +112,9 @@ function PairPanel() { }, [error, request]) useEffect(() => { - if (!status?.access_token || !status.panel) return + const panel = status?.device ?? status?.panel + if (!status?.access_token || !panel) return const token = status.access_token - const panel = status.panel const land = () => { // 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 @@ -137,9 +138,8 @@ function PairPanel() {

Pair this panel

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

diff --git a/frontend/tests/panel.spec.ts b/frontend/tests/panel.spec.ts index 8632086..851ac57 100644 --- a/frontend/tests/panel.spec.ts +++ b/frontend/tests/panel.spec.ts @@ -1,5 +1,4 @@ import { expect, type Page, test } from "@playwright/test" -import type { PanelDef } from "../src/client" import { api, apiPage, deleteAll } from "./utils/api" /** @@ -17,8 +16,6 @@ import { api, apiPage, deleteAll } from "./utils/api" const flowName = `test_panel_${Date.now().toString(36)}` const first = `${flowName}_a` const second = `${flowName}_b` -/** The instance's own panels, put back by the teardown. */ -let panels: PanelDef[] | null = null test.use({ storageState: "playwright/.auth/user.json" }) test.describe.configure({ mode: "serial" }) @@ -92,36 +89,20 @@ test.beforeAll(async ({ browser }) => { }) } - // Saving is a whole-list replace, and a panel that disappears takes the - // credential of the screen hanging on it. So append to what is there, and - // keep the list for the teardown to put back. - const config = await (await api(page, "/panels/")).json() - const existing: PanelDef[] = (config.panels ?? []).filter( - (p: PanelDef) => p.id !== flowName, - ) - panels = existing - await api(page, "/panels/", { + // A device of its own, saved on its own: nothing else on the instance moves. + const device = await ( + await api(page, `/devices/${flowName}`, { method: "POST" }) + ).json() + await api(page, `/devices/${flowName}`, { method: "PUT", - data: { - panels: [ - ...existing, - { id: flowName, title: "Hall", dashboards: [first, second] }, - ], - version: config.version, - }, + data: { ...device, title: "Hall", dashboards: [first, second] }, }) await page.close() }) test.afterAll(async ({ browser }) => { - // Restoring the list is also what removes this spec's own panel. - if (panels) { - const page = await apiPage(browser) - const { version } = await (await api(page, "/panels/")).json() - await api(page, "/panels/", { method: "PUT", data: { panels, version } }) - await page.close() - } await deleteAll(browser, [ + `/devices/${flowName}`, `/dashboards/${first}`, `/dashboards/${second}`, `/flows/${flowName}`, @@ -233,17 +214,12 @@ test("a touched panel grows its rail without widening it", async ({ page }) => { await setTouch(page, false) }) -/** What the Panels dialog writes, as the API sees it. */ +/** What the device's Touch friendly switch writes, as the API sees it. */ async function setTouch(page: Page, touch: boolean) { - const config = await (await api(page, "/panels/")).json() - await api(page, "/panels/", { + const device = await (await api(page, `/devices/${flowName}`)).json() + await api(page, `/devices/${flowName}`, { method: "PUT", - data: { - panels: (config.panels ?? []).map((panel: PanelDef) => - panel.id === flowName ? { ...panel, touch } : panel, - ), - version: config.version, - }, + data: { ...device, touch }, }) } diff --git a/zensical.toml b/zensical.toml index a1715de..43e5c5d 100644 --- a/zensical.toml +++ b/zensical.toml @@ -36,7 +36,7 @@ nav = [ { "The interface" = [ { "The dashboard app" = "interface/index.md" }, { "The flow editor" = "interface/flow-editor.md" }, - { "Dashboards and panels" = "interface/dashboards.md" }, + { "Dashboards and devices" = "interface/dashboards.md" }, { "Secrets, modules and alerts" = "interface/operations.md" }, { "Accounts and the portal" = "interface/portal.md" }, ] },