diff --git a/backend/app/api/routes/dashboards.py b/backend/app/api/routes/dashboards.py
index 839e430..960f488 100644
--- a/backend/app/api/routes/dashboards.py
+++ b/backend/app/api/routes/dashboards.py
@@ -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)
diff --git a/backend/app/api/routes/panels.py b/backend/app/api/routes/panels.py
index 780769e..e1906bc 100644
--- a/backend/app/api/routes/panels.py
+++ b/backend/app/api/routes/panels.py
@@ -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)
diff --git a/backend/app/cloud/connector.py b/backend/app/cloud/connector.py
index ed74943..db165a9 100644
--- a/backend/app/cloud/connector.py
+++ b/backend/app/cloud/connector.py
@@ -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)})
diff --git a/backend/app/flow/dashboards.py b/backend/app/flow/dashboards.py
index a049e01..940980f 100644
--- a/backend/app/flow/dashboards.py
+++ b/backend/app/flow/dashboards.py
@@ -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:
diff --git a/backend/app/flow/schemas.py b/backend/app/flow/schemas.py
index eb53ebc..c0ace0c 100644
--- a/backend/app/flow/schemas.py
+++ b/backend/app/flow/schemas.py
@@ -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):
diff --git a/backend/tests/api/routes/test_panels.py b/backend/tests/api/routes/test_panels.py
index d71d0c2..b73f908 100644
--- a/backend/tests/api/routes/test_panels.py
+++ b/backend/tests/api/routes/test_panels.py
@@ -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
diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py
index 4c9f07f..5d51cd4 100644
--- a/backend/tests/flow/test_dashboards.py
+++ b/backend/tests/flow/test_dashboards.py
@@ -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"))
diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py
index efd85b8..1c5f482 100644
--- a/backend/tests/test_cloud.py
+++ b/backend/tests/test_cloud.py
@@ -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}"}
diff --git a/frontend/src/components/Dashboard/PanelsDialog.tsx b/frontend/src/components/Dashboard/PanelsDialog.tsx
index ebb94fc..f4850fd 100644
--- a/frontend/src/components/Dashboard/PanelsDialog.tsx
+++ b/frontend/src/components/Dashboard/PanelsDialog.tsx
@@ -67,6 +67,8 @@ export function PanelsDialog() {
: ""
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("")
const panels = config?.panels ?? []
@@ -113,7 +115,7 @@ export function PanelsDialog() {
panel={panel}
host={host}
remoteHost={remoteHost}
- canPair={Boolean(user?.is_superuser)}
+ canEdit={canEdit}
dashboards={known.map((dashboard) => ({
name: dashboard.name,
title: dashboard.title || dashboard.name,
@@ -125,42 +127,48 @@ export function PanelsDialog() {
/>
))}
-
- There is already a panel called {newId}. -
+ + {taken ? ( ++ There is already a panel called {newId}. +
+ ) : null} + > ) : null} @@ -171,7 +179,7 @@ function PanelRow({ panel, host, remoteHost, - canPair, + canEdit, dashboards, onChange, onRemove, @@ -181,8 +189,12 @@ function PanelRow({ host: string /** Where the portal serves this installation, when it is enrolled. */ 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 }[] onChange: (next: PanelDef) => void onRemove: () => void @@ -197,7 +209,7 @@ function PanelRow({ const { data: waiting } = useQuery({ queryKey: ["pending-device", typed], queryFn: () => PanelsService.pendingDevice({ code: typed }), - enabled: canPair && typed.length === CODE_LENGTH, + enabled: canEdit && typed.length === CODE_LENGTH, retry: false, }) @@ -236,6 +248,7 @@ function PanelRow({ value={panel.title} placeholder={panel.id} aria-label={`Title of ${panel.id}`} + disabled={!canEdit} onChange={(event) => onChange({ ...panel, title: event.target.value }) } @@ -249,6 +262,7 @@ function PanelRow({ className="size-11 shrink-0 text-muted-foreground md:size-8" aria-label={`Remove ${panel.id}`} data-testid={`remove-panel-${panel.id}`} + disabled={!canEdit} onClick={onRemove} >