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() { /> ))} - + {canEdit ? ( + <> + -
{ - event.preventDefault() - if (!newId || taken) return - write({ panels: [...panels, { id: newId, title: name.trim() }] }) - setName("") - }} - > -
- - setName(event.target.value)} - /> -
- -
- {taken ? ( -

- There is already a panel called {newId}. -

+
{ + event.preventDefault() + if (!newId || taken) return + write({ + panels: [...panels, { id: newId, title: name.trim() }], + }) + setName("") + }} + > +
+ + setName(event.target.value)} + /> +
+ +
+ {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} > @@ -274,6 +288,7 @@ function PanelRow({ id={id} checked={position >= 0} data-testid={id} + disabled={!canEdit} onCheckedChange={() => toggle(dashboard.name)} /> {dashboard.title} @@ -312,7 +327,7 @@ function PanelRow({ ) : null} - {canPair ? ( + {canEdit ? ( <>
apiToken() -/** A session the server will not accept, whatever we do next. */ -const isAuthFailure = (error: unknown) => - error instanceof ApiError && [401, 403].includes(error.status) +/** + * A credential the server will not accept, so the session is over. + * + * 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 offline = offlineDetail(error) @@ -38,16 +53,12 @@ const handleApiError = (error: Error) => { connectionStore.setOffline(offline.lastSeen) return } - if (isAuthFailure(error)) { + if (isSessionGone(error)) { // 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: - // a 403 there is a dashboard it was just unassigned from, which the next - // read of the panel corrects on its own. + // new code instead. if (appRoute().startsWith("/panel")) { - if (error instanceof ApiError && error.status === 401) { - localStorage.removeItem("access_token") - window.location.href = appPath("/panel") - } + localStorage.removeItem("access_token") + window.location.href = appPath("/panel") return } if (portal) { @@ -58,6 +69,14 @@ const handleApiError = (error: Error) => { } localStorage.removeItem("access_token") 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: { queries: { // 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: { retry: false, diff --git a/frontend/src/routes/_layout/dashboards/index.tsx b/frontend/src/routes/_layout/dashboards/index.tsx index 583fcaf..ffae504 100644 --- a/frontend/src/routes/_layout/dashboards/index.tsx +++ b/frontend/src/routes/_layout/dashboards/index.tsx @@ -60,19 +60,16 @@ function Dashboards() { onError: handleError.bind(showErrorToast), }) - // A summary carries no version, and publishing needs the one it is based on — - // so each dashboard's working copy is read right before it is published. + // The summary carries the working copy's version, which is the one publishing + // is based on — so the list already holds everything this needs. const publishAll = usePublishAll( - async (dashboard) => { - const current = await DashboardsService.readDashboard({ + (dashboard) => + DashboardsService.publishDashboard({ name: dashboard, - draft: true, - }) - return DashboardsService.publishDashboard({ - name: dashboard, - requestBody: { version: current.version ?? 1 }, - }) - }, + requestBody: { + version: data?.data.find((d) => d.name === dashboard)?.version ?? 1, + }, + }), "dashboard", () => queryClient.invalidateQueries({ queryKey: dashboardKeys.all }), ) diff --git a/frontend/src/routes/_layout/flows/index.tsx b/frontend/src/routes/_layout/flows/index.tsx index 2bb3289..449ce80 100644 --- a/frontend/src/routes/_layout/flows/index.tsx +++ b/frontend/src/routes/_layout/flows/index.tsx @@ -53,16 +53,16 @@ function Flows() { onError: handleError.bind(showErrorToast), }) - // A summary carries no version, and publishing needs the one it is based - // on — so each flow's current version is read right before it is published. + // The summary carries the working copy's version, which is the one publishing + // is based on — so the list already holds everything this needs. const publishAll = usePublishAll( - async (flow) => { - const detail = await FlowsService.readFlow({ name: flow }) - return FlowsService.publishFlow({ + (flow) => + FlowsService.publishFlow({ name: flow, - requestBody: { version: detail.definition.version ?? 1 }, - }) - }, + requestBody: { + version: data?.data.find((f) => f.name === flow)?.version ?? 1, + }, + }), "flow", () => queryClient.invalidateQueries({ queryKey: flowKeys.all }), ) diff --git a/frontend/tests/endpoints.spec.ts b/frontend/tests/endpoints.spec.ts index a7249be..1dada2d 100644 --- a/frontend/tests/endpoints.spec.ts +++ b/frontend/tests/endpoints.spec.ts @@ -52,9 +52,8 @@ test.beforeAll(async ({ browser }) => { data: { version: saved.definition.version }, }) - await api(page, `/dashboards/${dashboardName}`, { method: "POST" }) const dashboard = await ( - await api(page, `/dashboards/${dashboardName}`) + await api(page, `/dashboards/${dashboardName}`, { method: "POST" }) ).json() dashboard.pages[0].sections[0].widgets = [ { diff --git a/frontend/tests/widgets.spec.ts b/frontend/tests/widgets.spec.ts index 4c55168..9cbbf1f 100644 --- a/frontend/tests/widgets.spec.ts +++ b/frontend/tests/widgets.spec.ts @@ -81,9 +81,8 @@ test.beforeAll(async ({ browser }) => { { label: "Sat", icon: "sun", value: "9°" }, ]) - await api(page, `/dashboards/${dashboardName}`, { method: "POST" }) const dashboard = await ( - await api(page, `/dashboards/${dashboardName}`) + await api(page, `/dashboards/${dashboardName}`, { method: "POST" }) ).json() dashboard.pages[0].sections[0].widgets = [ { @@ -179,8 +178,9 @@ test.beforeAll(async ({ browser }) => { data: { version: draft.version }, }) - await api(page, `/dashboards/${stackName}`, { method: "POST" }) - const stack = await (await api(page, `/dashboards/${stackName}`)).json() + const stack = await ( + await api(page, `/dashboards/${stackName}`, { method: "POST" }) + ).json() stack.pages[0].sections[0].widgets = [ { id: "split",