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
+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}"}