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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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}"}
|
||||
|
||||
Reference in New Issue
Block a user