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 335e9182af
commit e8a818a50b
19 changed files with 273 additions and 120 deletions
+24 -7
View File
@@ -1,5 +1,6 @@
"""Dashboards: documents of widgets bound to message names."""
import time
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
@@ -14,6 +15,7 @@ from app.flow.dashboards import (
DashboardsPublic,
default_dashboard,
)
from app.flow.events import event_bus
from app.flow.store import StaleVersion
from app.models import Message
@@ -54,19 +56,21 @@ async def read_dashboard(
@router.post("/{name}", response_model=DashboardDef)
async def create_dashboard(
name: str, store: DashboardStoreDep, controller: FlowControllerDep
) -> Any:
"""Start a dashboard: one page, one section, nothing on it yet."""
async def create_dashboard(name: str, store: DashboardStoreDep) -> 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):
raise HTTPException(status_code=409, detail=f"'{name}' already exists")
try:
defn = default_dashboard(name)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
saved = await run_in_threadpool(store.write, defn, 0)
await run_in_threadpool(_apply_history_limits, store, controller)
return saved
# No history limits to apply: they are read from the published documents,
# and this one is not one of them yet.
return await run_in_threadpool(store.write_draft, defn, 0)
@router.put("/{name}", response_model=DashboardDef)
@@ -123,6 +127,11 @@ async def publish_dashboard(
},
)
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
@@ -135,6 +144,14 @@ async def discard_dashboard_draft(name: str, store: DashboardStoreDep) -> Any:
raise HTTPException(
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)
+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.core import security
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.models import Message
@@ -213,6 +214,10 @@ async def save_panels(body: PanelsConfig) -> Any:
seen.add(panel.id)
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)
+20 -1
View File
@@ -382,7 +382,26 @@ class CloudConnector:
async with event_bus.subscribe() as queue:
while True:
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
await socket.send(
_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
published ``dashboard.json``, so a half-arranged page never reaches the wall.
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
@@ -268,6 +270,9 @@ class DashboardDef(BaseModel):
#: "unset" and the client falls back to its default.
canvas_width: int = Field(default=1920, ge=0, le=7680)
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)
#: Bumped on every save; a save based on an older one is refused.
version: int = 1
@@ -293,6 +298,8 @@ class DashboardSummary(BaseModel):
page_count: int = 0
widget_count: int = 0
has_draft: bool = False
#: Of the working copy, so publishing from a list needs no second read.
version: int = 1
class DashboardsPublic(BaseModel):
@@ -337,10 +344,13 @@ class DashboardStore:
return defn.model_dump_json(indent=2, exclude={"has_draft"})
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 = []
for path in sorted(self.root.glob("*/dashboard.json")):
for name in sorted(names):
try:
defn = DashboardDef.model_validate_json(path.read_text())
defn = self.read(name, draft=True)
except Exception:
continue
summaries.append(
@@ -349,12 +359,17 @@ class DashboardStore:
title=defn.title,
page_count=len(defn.pages),
widget_count=len(defn.widgets),
has_draft=self.has_draft(defn.name),
has_draft=defn.has_draft,
version=defn.version,
)
)
return summaries
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()
def has_draft(self, name: str) -> bool:
@@ -375,10 +390,10 @@ class DashboardStore:
def write(
self, defn: DashboardDef, base_version: int | None = None
) -> 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
writes the published file of a dashboard nobody has a draft of.
The API never does: it creates a draft and promotes it. This is for a
caller that already has the finished document — a test, or a seed.
"""
with self._lock, self.flows._write_lock:
path = self._file(defn.name)
@@ -400,12 +415,13 @@ class DashboardStore:
"""Save unpublished changes, refusing to overwrite someone else's.
``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:
if not self.exists(defn.name):
raise DashboardNotFound(defn.name)
current = self.read(defn.name, draft=True).version
current = 0
if self.exists(defn.name):
current = self.read(defn.name, draft=True).version
if base_version is not None and base_version != current:
raise StaleVersion(defn.name, current)
@@ -440,11 +456,11 @@ class DashboardStore:
return self.read(name)
def delete(self, name: str) -> None:
path = self._file(name)
if not path.exists():
if not self.exists(name):
raise DashboardNotFound(name)
path = self._file(name)
with self.flows._write_lock:
path.unlink()
path.unlink(missing_ok=True)
self._draft_file(name).unlink(missing_ok=True)
try:
path.parent.rmdir()
@@ -453,22 +469,22 @@ class DashboardStore:
self.flows._commit(f"Delete dashboard '{name}'")
def rename(self, name: str, new_name: str) -> DashboardDef:
defn = self.read(name)
defn = self.read(name, draft=True)
if self.exists(new_name):
raise DashboardExists(new_name)
with self.flows._write_lock:
renamed = defn.model_copy(update={"name": new_name})
target = self._file(new_name)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(self._dump(renamed))
self._file(new_name).parent.mkdir(parents=True, exist_ok=True)
# Whichever files the dashboard has move; one nobody published yet
# 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.
if self.has_draft(name):
draft = self.read(name, draft=True)
self._draft_file(new_name).write_text(
self._dump(draft.model_copy(update={"name": new_name}))
)
self._draft_file(new_name).write_text(self._dump(renamed))
self._draft_file(name).unlink()
self._file(name).unlink()
try:
self._file(name).parent.rmdir()
except OSError:
+6
View File
@@ -132,6 +132,10 @@ class NodeStatusPublic(BaseModel):
error: str | None = None
health: Health = "ok"
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):
@@ -170,6 +174,8 @@ class FlowSummary(BaseModel):
paused: bool = False
# Its background tasks kept crashing, so the engine stopped restarting them.
quarantined: bool = False
#: Of the working copy, so publishing from a list needs no second read.
version: int = 1
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
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]:
"""Walk a device through pairing and return the header it ends up with."""
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]
) -> None:
for name in ("panel_shown", "panel_hidden"):
client.post(f"{DASHBOARDS}/{name}", headers=superuser_token_headers)
_dashboard(client, superuser_token_headers, name)
_panels(
client,
superuser_token_headers,
@@ -187,7 +203,7 @@ def test_paired_panel_reaches_only_what_it_shows(
def test_removing_the_panel_revokes_its_credential(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
client.post(f"{DASHBOARDS}/panel_gone", headers=superuser_token_headers)
_dashboard(client, superuser_token_headers, "panel_gone")
_panels(
client,
superuser_token_headers,
@@ -368,10 +384,7 @@ def test_a_panels_socket_carries_only_what_it_draws(
snapshot_payload,
)
client.post(f"{DASHBOARDS}/panel_socket", headers=superuser_token_headers)
saved = client.get(
f"{DASHBOARDS}/panel_socket", headers=superuser_token_headers
).json()
saved = _dashboard(client, superuser_token_headers, "panel_socket")
saved["pages"] = [
{
"id": "main",
@@ -435,6 +448,9 @@ def test_a_panels_socket_carries_only_what_it_draws(
{"type": "message_value", "name": "house.safe.code"}, 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.
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")
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):
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
)
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")
headers = {"Authorization": f"Bearer {token}"}