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