Hold a new dashboard back until someone publishes it

A dashboard went live the moment it was created — an empty document straight to
the panels — while a new flow starts as a draft. It now works the way flows do:
published means `dashboard.json` exists, so every dashboard on every running
installation is already published and nothing needs migrating. Only the ones
created from here on start as drafts.

Mirroring FlowStore turned up a latent 500: discarding the draft of a dashboard
that had never been published unlinked its only file, and the read that followed
raised out of a 200 handler. It answers 400 now, the way a flow does.

Publishing all of them was 2N requests, because a publish has to name the
version it expects and the summaries did not carry one. They do now — and so do
the flow summaries, which had the same defect nobody had written down.

A panel had no way to hear about any of this. A publish, or a change to which
dashboards a panel carries, now puts one event on the bus and the screen
refetches what changed: no reload, so a wall display never blanks or asks for
its credential again. The subtle half is that a socket's message allowlist was
computed once at handshake — a reassigned panel would have fetched its new
document and then shown tiles that never updated.

The panels dialog logged non-superusers out. Every write in it needs a
superuser, not only the checkboxes the report mentioned, so the dialog is
read-only for everyone else. The logout itself was `main.tsx` treating 403 as a
dead session, against the contract deps.py spells out: only a 401 ends a
session, and a 403 now says so rather than silently signing someone out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
This commit is contained in:
2026-08-21 14:32:57 +02:00
co-authored by Claude Opus 5
parent 000c5abf91
commit c3ea884d72
14 changed files with 268 additions and 115 deletions
+24 -7
View File
@@ -1,5 +1,6 @@
"""Dashboards: documents of widgets bound to message names.""" """Dashboards: documents of widgets bound to message names."""
import time
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -14,6 +15,7 @@ from app.flow.dashboards import (
DashboardsPublic, DashboardsPublic,
default_dashboard, default_dashboard,
) )
from app.flow.events import event_bus
from app.flow.store import StaleVersion from app.flow.store import StaleVersion
from app.models import Message from app.models import Message
@@ -54,19 +56,21 @@ async def read_dashboard(
@router.post("/{name}", response_model=DashboardDef) @router.post("/{name}", response_model=DashboardDef)
async def create_dashboard( async def create_dashboard(name: str, store: DashboardStoreDep) -> Any:
name: str, store: DashboardStoreDep, controller: FlowControllerDep """Start a dashboard: one page, one section, nothing on it yet.
) -> Any:
"""Start a dashboard: one page, one section, nothing on it yet.""" A draft, like every edit that follows it — a dashboard reaches a panel
only once someone publishes it, so an empty one never does.
"""
if await run_in_threadpool(store.exists, name): if await run_in_threadpool(store.exists, name):
raise HTTPException(status_code=409, detail=f"'{name}' already exists") raise HTTPException(status_code=409, detail=f"'{name}' already exists")
try: try:
defn = default_dashboard(name) defn = default_dashboard(name)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) raise HTTPException(status_code=422, detail=str(exc))
saved = await run_in_threadpool(store.write, defn, 0) # No history limits to apply: they are read from the published documents,
await run_in_threadpool(_apply_history_limits, store, controller) # and this one is not one of them yet.
return saved return await run_in_threadpool(store.write_draft, defn, 0)
@router.put("/{name}", response_model=DashboardDef) @router.put("/{name}", response_model=DashboardDef)
@@ -123,6 +127,11 @@ async def publish_dashboard(
}, },
) )
await run_in_threadpool(_apply_history_limits, store, controller) await run_in_threadpool(_apply_history_limits, store, controller)
# What a panel is showing has changed. Panels watch the flow socket, and
# the tile values alone cannot tell them the document itself moved.
event_bus.publish(
{"type": "dashboard_changed", "dashboard": name, "ts": time.time()}
)
return published return published
@@ -135,6 +144,14 @@ async def discard_dashboard_draft(name: str, store: DashboardStoreDep) -> Any:
raise HTTPException( raise HTTPException(
status_code=400, detail=f"Dashboard '{name}' has no unpublished changes" status_code=400, detail=f"Dashboard '{name}' has no unpublished changes"
) )
if not await run_in_threadpool(store.is_published, name):
raise HTTPException(
status_code=400,
detail=(
f"Dashboard '{name}' has never been published — delete it instead "
"of discarding it"
),
)
return await run_in_threadpool(store.discard_draft, name) return await run_in_threadpool(store.discard_draft, name)
+5
View File
@@ -31,6 +31,7 @@ from app.api.deps import CurrentUser, get_current_active_superuser, get_current_
from app.cloud import config as cloud_config from app.cloud import config as cloud_config
from app.core import security from app.core import security
from app.core.config import settings from app.core.config import settings
from app.flow.events import event_bus
from app.flow.panels import PanelDef, PanelsConfig, find, read_config, write_config from app.flow.panels import PanelDef, PanelsConfig, find, read_config, write_config
from app.models import Message from app.models import Message
@@ -213,6 +214,10 @@ async def save_panels(body: PanelsConfig) -> Any:
seen.add(panel.id) seen.add(panel.id)
await run_in_threadpool(write_config, body) await run_in_threadpool(write_config, body)
# 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) return _public(body)
+20 -1
View File
@@ -382,7 +382,26 @@ class CloudConnector:
async with event_bus.subscribe() as queue: async with event_bus.subscribe() as queue:
while True: while True:
event = await queue.get() event = await queue.get()
if only is not None and not event_for_panel(event, only): if only is not None and event.get("type") == "dashboard_changed":
# What this screen shows may have just changed under
# it. Rescope, then resend the snapshot so a dashboard
# it has only now been assigned draws values instead of
# blanks. ``or set()`` is the point: a panel that was
# deleted scopes to nothing, and None would widen this
# socket to everything on the bus.
only = panel_scope(token, self._app) or set()
if controller is not None:
catch_up = snapshot_payload(controller, only)
await socket.send(
_dump(
{
"op": "ws_msg",
"id": stream_id,
"text": _dump(catch_up),
}
)
)
elif only is not None and not event_for_panel(event, only):
continue continue
await socket.send( await socket.send(
_dump({"op": "ws_msg", "id": stream_id, "text": _dump(event)}) _dump({"op": "ws_msg", "id": stream_id, "text": _dump(event)})
+39 -23
View File
@@ -10,7 +10,9 @@ listing ignores. Editing is separated from showing, exactly as it is for flows:
the editor writes ``dashboard.draft.json`` and a wall panel reads only the the editor writes ``dashboard.draft.json`` and a wall panel reads only the
published ``dashboard.json``, so a half-arranged page never reaches the wall. published ``dashboard.json``, so a half-arranged page never reaches the wall.
Publishing promotes the draft and removes it; a dashboard directory without one Publishing promotes the draft and removes it; a dashboard directory without one
is simply a dashboard with nothing unpublished. is simply a dashboard with nothing unpublished. A new dashboard starts as a
draft alone, so a directory may just as well hold only the draft — a dashboard
nobody has published yet, which no panel can be shown.
""" """
from __future__ import annotations from __future__ import annotations
@@ -268,6 +270,9 @@ class DashboardDef(BaseModel):
#: "unset" and the client falls back to its default. #: "unset" and the client falls back to its default.
canvas_width: int = Field(default=1920, ge=0, le=7680) canvas_width: int = Field(default=1920, ge=0, le=7680)
canvas_height: int = Field(default=1080, ge=0, le=4320) canvas_height: int = Field(default=1080, ge=0, le=4320)
#: A lucide icon name, drawn on the panel rail; empty falls back to two
#: letters of the title.
icon: str = ""
pages: list[PageDef] = Field(default_factory=list) pages: list[PageDef] = Field(default_factory=list)
#: Bumped on every save; a save based on an older one is refused. #: Bumped on every save; a save based on an older one is refused.
version: int = 1 version: int = 1
@@ -293,6 +298,8 @@ class DashboardSummary(BaseModel):
page_count: int = 0 page_count: int = 0
widget_count: int = 0 widget_count: int = 0
has_draft: bool = False has_draft: bool = False
#: Of the working copy, so publishing from a list needs no second read.
version: int = 1
class DashboardsPublic(BaseModel): class DashboardsPublic(BaseModel):
@@ -337,10 +344,13 @@ class DashboardStore:
return defn.model_dump_json(indent=2, exclude={"has_draft"}) return defn.model_dump_json(indent=2, exclude={"has_draft"})
def list(self) -> list[DashboardSummary]: def list(self) -> list[DashboardSummary]:
"""Every dashboard the editor knows, published or not."""
names = {path.parent.name for path in self.root.glob("*/dashboard.json")}
names |= {path.parent.name for path in self.root.glob("*/dashboard.draft.json")}
summaries = [] summaries = []
for path in sorted(self.root.glob("*/dashboard.json")): for name in sorted(names):
try: try:
defn = DashboardDef.model_validate_json(path.read_text()) defn = self.read(name, draft=True)
except Exception: except Exception:
continue continue
summaries.append( summaries.append(
@@ -349,12 +359,17 @@ class DashboardStore:
title=defn.title, title=defn.title,
page_count=len(defn.pages), page_count=len(defn.pages),
widget_count=len(defn.widgets), widget_count=len(defn.widgets),
has_draft=self.has_draft(defn.name), has_draft=defn.has_draft,
version=defn.version,
) )
) )
return summaries return summaries
def exists(self, name: str) -> bool: def exists(self, name: str) -> bool:
return self._file(name).exists() or self._draft_file(name).exists()
def is_published(self, name: str) -> bool:
"""Is there a document a panel can be shown?"""
return self._file(name).exists() return self._file(name).exists()
def has_draft(self, name: str) -> bool: def has_draft(self, name: str) -> bool:
@@ -375,10 +390,10 @@ class DashboardStore:
def write( def write(
self, defn: DashboardDef, base_version: int | None = None self, defn: DashboardDef, base_version: int | None = None
) -> DashboardDef: ) -> DashboardDef:
"""Publish a dashboard directly — what creating one does. """Publish a dashboard directly, skipping the draft.
Every later edit goes through :meth:`write_draft`, so this only ever The API never does: it creates a draft and promotes it. This is for a
writes the published file of a dashboard nobody has a draft of. caller that already has the finished document — a test, or a seed.
""" """
with self._lock, self.flows._write_lock: with self._lock, self.flows._write_lock:
path = self._file(defn.name) path = self._file(defn.name)
@@ -400,12 +415,13 @@ class DashboardStore:
"""Save unpublished changes, refusing to overwrite someone else's. """Save unpublished changes, refusing to overwrite someone else's.
``base_version`` is the version the editor last saw — of the working ``base_version`` is the version the editor last saw — of the working
copy, which is the draft once there is one. copy, which is the draft once there is one, and 0 for a dashboard that
does not exist yet: creating one is its first draft.
""" """
with self._lock, self.flows._write_lock: with self._lock, self.flows._write_lock:
if not self.exists(defn.name): current = 0
raise DashboardNotFound(defn.name) if self.exists(defn.name):
current = self.read(defn.name, draft=True).version current = self.read(defn.name, draft=True).version
if base_version is not None and base_version != current: if base_version is not None and base_version != current:
raise StaleVersion(defn.name, current) raise StaleVersion(defn.name, current)
@@ -440,11 +456,11 @@ class DashboardStore:
return self.read(name) return self.read(name)
def delete(self, name: str) -> None: def delete(self, name: str) -> None:
path = self._file(name) if not self.exists(name):
if not path.exists():
raise DashboardNotFound(name) raise DashboardNotFound(name)
path = self._file(name)
with self.flows._write_lock: with self.flows._write_lock:
path.unlink() path.unlink(missing_ok=True)
self._draft_file(name).unlink(missing_ok=True) self._draft_file(name).unlink(missing_ok=True)
try: try:
path.parent.rmdir() path.parent.rmdir()
@@ -453,22 +469,22 @@ class DashboardStore:
self.flows._commit(f"Delete dashboard '{name}'") self.flows._commit(f"Delete dashboard '{name}'")
def rename(self, name: str, new_name: str) -> DashboardDef: def rename(self, name: str, new_name: str) -> DashboardDef:
defn = self.read(name) defn = self.read(name, draft=True)
if self.exists(new_name): if self.exists(new_name):
raise DashboardExists(new_name) raise DashboardExists(new_name)
with self.flows._write_lock: with self.flows._write_lock:
renamed = defn.model_copy(update={"name": new_name}) renamed = defn.model_copy(update={"name": new_name})
target = self._file(new_name) self._file(new_name).parent.mkdir(parents=True, exist_ok=True)
target.parent.mkdir(parents=True, exist_ok=True) # Whichever files the dashboard has move; one nobody published yet
target.write_text(self._dump(renamed)) # has only the draft, and renaming it must not publish it.
if self.is_published(name):
published = self.read(name).model_copy(update={"name": new_name})
self._file(new_name).write_text(self._dump(published))
self._file(name).unlink()
# An unpublished edit belongs to the dashboard, so it moves too. # An unpublished edit belongs to the dashboard, so it moves too.
if self.has_draft(name): if self.has_draft(name):
draft = self.read(name, draft=True) self._draft_file(new_name).write_text(self._dump(renamed))
self._draft_file(new_name).write_text(
self._dump(draft.model_copy(update={"name": new_name}))
)
self._draft_file(name).unlink() self._draft_file(name).unlink()
self._file(name).unlink()
try: try:
self._file(name).parent.rmdir() self._file(name).parent.rmdir()
except OSError: except OSError:
+6
View File
@@ -132,6 +132,10 @@ class NodeStatusPublic(BaseModel):
error: str | None = None error: str | None = None
health: Health = "ok" health: Health = "ok"
health_detail: str | None = None health_detail: str | None = None
#: The node's last runtime failure, kept after it runs again: a failure
#: that fired an alert should leave a trace of what it was.
last_error: str = ""
last_error_ts: float | None = None
class MessageValue(BaseModel): class MessageValue(BaseModel):
@@ -170,6 +174,8 @@ class FlowSummary(BaseModel):
paused: bool = False paused: bool = False
# Its background tasks kept crashing, so the engine stopped restarting them. # Its background tasks kept crashing, so the engine stopped restarting them.
quarantined: bool = False quarantined: bool = False
#: Of the working copy, so publishing from a list needs no second read.
version: int = 1
class FlowsPublic(BaseModel): class FlowsPublic(BaseModel):
+22 -6
View File
@@ -14,6 +14,22 @@ def _panels(client: TestClient, headers: dict[str, str], config: dict) -> None:
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
def _dashboard(client: TestClient, headers: dict[str, str], name: str) -> dict:
"""Create a dashboard and publish it.
A new one is a draft, and a panel is only ever shown what is published.
"""
created = client.post(f"{DASHBOARDS}/{name}", headers=headers)
assert created.status_code == 200, created.text
published = client.post(
f"{DASHBOARDS}/{name}/publish",
headers=headers,
json={"version": created.json()["version"]},
)
assert published.status_code == 200, published.text
return published.json()
def _pair(client: TestClient, headers: dict[str, str], panel: str) -> dict[str, str]: def _pair(client: TestClient, headers: dict[str, str], panel: str) -> dict[str, str]:
"""Walk a device through pairing and return the header it ends up with.""" """Walk a device through pairing and return the header it ends up with."""
started = client.post(f"{PREFIX}/pair").json() started = client.post(f"{PREFIX}/pair").json()
@@ -122,7 +138,7 @@ def test_paired_panel_reaches_only_what_it_shows(
client: TestClient, superuser_token_headers: dict[str, str] client: TestClient, superuser_token_headers: dict[str, str]
) -> None: ) -> None:
for name in ("panel_shown", "panel_hidden"): for name in ("panel_shown", "panel_hidden"):
client.post(f"{DASHBOARDS}/{name}", headers=superuser_token_headers) _dashboard(client, superuser_token_headers, name)
_panels( _panels(
client, client,
superuser_token_headers, superuser_token_headers,
@@ -187,7 +203,7 @@ def test_paired_panel_reaches_only_what_it_shows(
def test_removing_the_panel_revokes_its_credential( def test_removing_the_panel_revokes_its_credential(
client: TestClient, superuser_token_headers: dict[str, str] client: TestClient, superuser_token_headers: dict[str, str]
) -> None: ) -> None:
client.post(f"{DASHBOARDS}/panel_gone", headers=superuser_token_headers) _dashboard(client, superuser_token_headers, "panel_gone")
_panels( _panels(
client, client,
superuser_token_headers, superuser_token_headers,
@@ -368,10 +384,7 @@ def test_a_panels_socket_carries_only_what_it_draws(
snapshot_payload, snapshot_payload,
) )
client.post(f"{DASHBOARDS}/panel_socket", headers=superuser_token_headers) saved = _dashboard(client, superuser_token_headers, "panel_socket")
saved = client.get(
f"{DASHBOARDS}/panel_socket", headers=superuser_token_headers
).json()
saved["pages"] = [ saved["pages"] = [
{ {
"id": "main", "id": "main",
@@ -435,6 +448,9 @@ def test_a_panels_socket_carries_only_what_it_draws(
{"type": "message_value", "name": "house.safe.code"}, only {"type": "message_value", "name": "house.safe.code"}, only
) )
assert not event_for_panel({"type": "node_log", "text": "a traceback"}, only) assert not event_for_panel({"type": "node_log", "text": "a traceback"}, only)
# Except a dashboard being published: that is how a screen hears the
# document it draws — or the set of them it was given — has moved.
assert event_for_panel({"type": "dashboard_changed", "dashboard": "x"}, only)
# A person's credential is not bounded at all. # A person's credential is not bounded at all.
assert panel_scope(superuser_token_headers["Authorization"][7:], client.app) is None assert panel_scope(superuser_token_headers["Authorization"][7:], client.app) is None
+32
View File
@@ -87,6 +87,38 @@ def test_discarding_leaves_what_is_published(store: DashboardStore):
assert not store.has_draft("house") assert not store.has_draft("house")
def test_a_new_dashboard_is_a_draft_until_it_is_published(store: DashboardStore):
"""Creating one does not put it on a wall; publishing is what does."""
created = store.write_draft(default_dashboard("house"), 0)
assert not store.is_published("house")
with pytest.raises(DashboardNotFound):
store.read("house")
assert store.read("house", draft=True).title == created.title
# Listed all the same, so the editor can find what it just made.
assert [(d.name, d.has_draft, d.version) for d in store.list()] == [
("house", True, created.version)
]
store.publish("house", created.version)
assert store.is_published("house")
assert store.read("house").title == created.title
def test_an_unpublished_dashboard_can_be_renamed_and_deleted(store: DashboardStore):
"""And renaming it does not put it on a wall either."""
store.write_draft(default_dashboard("house"), 0)
store.rename("house", "home")
assert not store.exists("house")
assert store.has_draft("home") and not store.is_published("home")
store.delete("home")
assert not store.exists("home")
def test_deleting_and_renaming(store: DashboardStore): def test_deleting_and_renaming(store: DashboardStore):
store.write(default_dashboard("house")) store.write(default_dashboard("house"))
+12
View File
@@ -135,6 +135,18 @@ def test_a_panel_scoped_portal_token_reaches_only_its_panel(
f"{settings.API_V1_STR}/dashboards/{name}", headers=superuser_token_headers f"{settings.API_V1_STR}/dashboards/{name}", headers=superuser_token_headers
) )
assert created.status_code in (200, 201, 409), created.text assert created.status_code in (200, 201, 409), created.text
# A new dashboard is a draft, and a panel only reaches what is
# published — so promote it before asking as one.
version = client.get(
f"{settings.API_V1_STR}/dashboards/{name}",
headers=superuser_token_headers,
params={"draft": "true"},
).json()["version"]
client.post(
f"{settings.API_V1_STR}/dashboards/{name}/publish",
headers=superuser_token_headers,
json={"version": version},
)
token = portal_token(portal_key, subject="hallway", scope="panel") token = portal_token(portal_key, subject="hallway", scope="panel")
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
@@ -67,6 +67,8 @@ export function PanelsDialog() {
: "" : ""
const save = useSavePanels() const save = useSavePanels()
const { showErrorToast } = useCustomToast() 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("") const [name, setName] = useState("")
const panels = config?.panels ?? [] const panels = config?.panels ?? []
@@ -113,7 +115,7 @@ export function PanelsDialog() {
panel={panel} panel={panel}
host={host} host={host}
remoteHost={remoteHost} remoteHost={remoteHost}
canPair={Boolean(user?.is_superuser)} canEdit={canEdit}
dashboards={known.map((dashboard) => ({ dashboards={known.map((dashboard) => ({
name: dashboard.name, name: dashboard.name,
title: dashboard.title || dashboard.name, title: dashboard.title || dashboard.name,
@@ -125,42 +127,48 @@ export function PanelsDialog() {
/> />
))} ))}
<Separator /> {canEdit ? (
<>
<Separator />
<form <form
className="flex items-end gap-2" className="flex items-end gap-2"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault() event.preventDefault()
if (!newId || taken) return if (!newId || taken) return
write({ panels: [...panels, { id: newId, title: name.trim() }] }) write({
setName("") panels: [...panels, { id: newId, title: name.trim() }],
}} })
> setName("")
<div className="grid flex-1 gap-1"> }}
<label className="text-sm" htmlFor="new-panel"> >
New panel <div className="grid flex-1 gap-1">
</label> <label className="text-sm" htmlFor="new-panel">
<Input New panel
id="new-panel" </label>
value={name} <Input
placeholder="hallway" id="new-panel"
autoComplete="off" value={name}
data-testid="new-panel-name" placeholder="hallway"
onChange={(event) => setName(event.target.value)} autoComplete="off"
/> data-testid="new-panel-name"
</div> onChange={(event) => setName(event.target.value)}
<Button />
type="submit" </div>
disabled={!newId || taken} <Button
data-testid="add-panel" type="submit"
> disabled={!newId || taken}
Add panel data-testid="add-panel"
</Button> >
</form> Add panel
{taken ? ( </Button>
<p className="text-sm text-destructive"> </form>
There is already a panel called {newId}. {taken ? (
</p> <p className="text-sm text-destructive">
There is already a panel called {newId}.
</p>
) : null}
</>
) : null} ) : null}
</div> </div>
</DialogContent> </DialogContent>
@@ -171,7 +179,7 @@ function PanelRow({
panel, panel,
host, host,
remoteHost, remoteHost,
canPair, canEdit,
dashboards, dashboards,
onChange, onChange,
onRemove, onRemove,
@@ -181,8 +189,12 @@ function PanelRow({
host: string host: string
/** Where the portal serves this installation, when it is enrolled. */ /** Where the portal serves this installation, when it is enrolled. */
remoteHost: string remoteHost: string
/** Approving a code is a superuser's, and so is asking what holds one. */ /**
canPair: boolean * 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 }[] dashboards: { name: string; title: string }[]
onChange: (next: PanelDef) => void onChange: (next: PanelDef) => void
onRemove: () => void onRemove: () => void
@@ -197,7 +209,7 @@ function PanelRow({
const { data: waiting } = useQuery({ const { data: waiting } = useQuery({
queryKey: ["pending-device", typed], queryKey: ["pending-device", typed],
queryFn: () => PanelsService.pendingDevice({ code: typed }), queryFn: () => PanelsService.pendingDevice({ code: typed }),
enabled: canPair && typed.length === CODE_LENGTH, enabled: canEdit && typed.length === CODE_LENGTH,
retry: false, retry: false,
}) })
@@ -236,6 +248,7 @@ function PanelRow({
value={panel.title} value={panel.title}
placeholder={panel.id} placeholder={panel.id}
aria-label={`Title of ${panel.id}`} aria-label={`Title of ${panel.id}`}
disabled={!canEdit}
onChange={(event) => onChange={(event) =>
onChange({ ...panel, title: event.target.value }) onChange({ ...panel, title: event.target.value })
} }
@@ -249,6 +262,7 @@ function PanelRow({
className="size-11 shrink-0 text-muted-foreground md:size-8" className="size-11 shrink-0 text-muted-foreground md:size-8"
aria-label={`Remove ${panel.id}`} aria-label={`Remove ${panel.id}`}
data-testid={`remove-panel-${panel.id}`} data-testid={`remove-panel-${panel.id}`}
disabled={!canEdit}
onClick={onRemove} onClick={onRemove}
> >
<Trash2 /> <Trash2 />
@@ -274,6 +288,7 @@ function PanelRow({
id={id} id={id}
checked={position >= 0} checked={position >= 0}
data-testid={id} data-testid={id}
disabled={!canEdit}
onCheckedChange={() => toggle(dashboard.name)} onCheckedChange={() => toggle(dashboard.name)}
/> />
<span className="flex-1 truncate">{dashboard.title}</span> <span className="flex-1 truncate">{dashboard.title}</span>
@@ -312,7 +327,7 @@ function PanelRow({
</span> </span>
</div> </div>
) : null} ) : null}
{canPair ? ( {canEdit ? (
<> <>
<form <form
className="flex gap-2" className="flex gap-2"
+31 -12
View File
@@ -8,6 +8,7 @@ import { createRouter, RouterProvider } from "@tanstack/react-router"
import { MotionConfig } from "motion/react" import { MotionConfig } from "motion/react"
import { StrictMode } from "react" import { StrictMode } from "react"
import ReactDOM from "react-dom/client" import ReactDOM from "react-dom/client"
import { toast } from "sonner"
import { ApiError, OpenAPI } from "./client" import { ApiError, OpenAPI } from "./client"
import { ThemeProvider } from "./components/theme-provider" import { ThemeProvider } from "./components/theme-provider"
import { Toaster } from "./components/ui/sonner" import { Toaster } from "./components/ui/sonner"
@@ -26,9 +27,23 @@ OpenAPI.BASE = portal
: import.meta.env.VITE_API_URL : import.meta.env.VITE_API_URL
OpenAPI.TOKEN = async () => apiToken() OpenAPI.TOKEN = async () => apiToken()
/** A session the server will not accept, whatever we do next. */ /**
const isAuthFailure = (error: unknown) => * A credential the server will not accept, so the session is over.
error instanceof ApiError && [401, 403].includes(error.status) *
* Only a 401 says that. `get_current_user` answers 401 for every
* authentication failure it has, which leaves 403 meaning the opposite: signed
* in, and reaching past what this account is allowed.
*/
const isSessionGone = (error: unknown) =>
error instanceof ApiError && error.status === 401
/** Signed in, but not permitted this. Nothing to do but say so. */
const isForbidden = (error: unknown) =>
error instanceof ApiError && error.status === 403
/** Neither answer changes on a second ask, so a retry only delays the news. */
const isPointlessToRetry = (error: unknown) =>
isSessionGone(error) || isForbidden(error)
const handleApiError = (error: Error) => { const handleApiError = (error: Error) => {
const offline = offlineDetail(error) const offline = offlineDetail(error)
@@ -38,16 +53,12 @@ const handleApiError = (error: Error) => {
connectionStore.setOffline(offline.lastSeen) connectionStore.setOffline(offline.lastSeen)
return return
} }
if (isAuthFailure(error)) { if (isSessionGone(error)) {
// A paired wall panel has no login screen to go back to — it asks for a // A paired wall panel has no login screen to go back to — it asks for a
// new code instead. Only a 401 is worth throwing its credential away for: // new code instead.
// a 403 there is a dashboard it was just unassigned from, which the next
// read of the panel corrects on its own.
if (appRoute().startsWith("/panel")) { if (appRoute().startsWith("/panel")) {
if (error instanceof ApiError && error.status === 401) { localStorage.removeItem("access_token")
localStorage.removeItem("access_token") window.location.href = appPath("/panel")
window.location.href = appPath("/panel")
}
return return
} }
if (portal) { if (portal) {
@@ -58,6 +69,14 @@ const handleApiError = (error: Error) => {
} }
localStorage.removeItem("access_token") localStorage.removeItem("access_token")
window.location.href = appPath("/login") window.location.href = appPath("/login")
return
}
if (isForbidden(error) && !appRoute().startsWith("/panel")) {
// The session stands, so stay put — but a refused change that says nothing
// reads as a broken button. On a panel there is nobody to read a toast: a
// 403 there is a dashboard it was just unassigned from, which its next read
// of the panel corrects on its own.
toast.error("You do not have permission to do that")
} }
} }
@@ -73,7 +92,7 @@ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
// Retrying an expired session only delays the trip to the login screen. // Retrying an expired session only delays the trip to the login screen.
retry: (count, error) => !isAuthFailure(error) && count < 3, retry: (count, error) => !isPointlessToRetry(error) && count < 3,
}, },
mutations: { mutations: {
retry: false, retry: false,
@@ -60,19 +60,16 @@ function Dashboards() {
onError: handleError.bind(showErrorToast), onError: handleError.bind(showErrorToast),
}) })
// A summary carries no version, and publishing needs the one it is based on — // The summary carries the working copy's version, which is the one publishing
// so each dashboard's working copy is read right before it is published. // is based on — so the list already holds everything this needs.
const publishAll = usePublishAll( const publishAll = usePublishAll(
async (dashboard) => { (dashboard) =>
const current = await DashboardsService.readDashboard({ DashboardsService.publishDashboard({
name: dashboard, name: dashboard,
draft: true, requestBody: {
}) version: data?.data.find((d) => d.name === dashboard)?.version ?? 1,
return DashboardsService.publishDashboard({ },
name: dashboard, }),
requestBody: { version: current.version ?? 1 },
})
},
"dashboard", "dashboard",
() => queryClient.invalidateQueries({ queryKey: dashboardKeys.all }), () => queryClient.invalidateQueries({ queryKey: dashboardKeys.all }),
) )
+8 -8
View File
@@ -53,16 +53,16 @@ function Flows() {
onError: handleError.bind(showErrorToast), onError: handleError.bind(showErrorToast),
}) })
// A summary carries no version, and publishing needs the one it is based // The summary carries the working copy's version, which is the one publishing
// on — so each flow's current version is read right before it is published. // is based on — so the list already holds everything this needs.
const publishAll = usePublishAll( const publishAll = usePublishAll(
async (flow) => { (flow) =>
const detail = await FlowsService.readFlow({ name: flow }) FlowsService.publishFlow({
return FlowsService.publishFlow({
name: flow, name: flow,
requestBody: { version: detail.definition.version ?? 1 }, requestBody: {
}) version: data?.data.find((f) => f.name === flow)?.version ?? 1,
}, },
}),
"flow", "flow",
() => queryClient.invalidateQueries({ queryKey: flowKeys.all }), () => queryClient.invalidateQueries({ queryKey: flowKeys.all }),
) )
+1 -2
View File
@@ -52,9 +52,8 @@ test.beforeAll(async ({ browser }) => {
data: { version: saved.definition.version }, data: { version: saved.definition.version },
}) })
await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
const dashboard = await ( const dashboard = await (
await api(page, `/dashboards/${dashboardName}`) await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
).json() ).json()
dashboard.pages[0].sections[0].widgets = [ dashboard.pages[0].sections[0].widgets = [
{ {
+4 -4
View File
@@ -81,9 +81,8 @@ test.beforeAll(async ({ browser }) => {
{ label: "Sat", icon: "sun", value: "9°" }, { label: "Sat", icon: "sun", value: "9°" },
]) ])
await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
const dashboard = await ( const dashboard = await (
await api(page, `/dashboards/${dashboardName}`) await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
).json() ).json()
dashboard.pages[0].sections[0].widgets = [ dashboard.pages[0].sections[0].widgets = [
{ {
@@ -179,8 +178,9 @@ test.beforeAll(async ({ browser }) => {
data: { version: draft.version }, data: { version: draft.version },
}) })
await api(page, `/dashboards/${stackName}`, { method: "POST" }) const stack = await (
const stack = await (await api(page, `/dashboards/${stackName}`)).json() await api(page, `/dashboards/${stackName}`, { method: "POST" })
).json()
stack.pages[0].sections[0].widgets = [ stack.pages[0].sections[0].widgets = [
{ {
id: "split", id: "split",