Overviews: icon toolbar, dashboard drafts, publish all

Both overviews carried the same toolbar twice, left-aligned, with a search
field permanently taking a row of width. One `OverviewToolbar` now serves
them: the search folds into an icon and expands again on click (Escape puts
it away and hands focus back), create is a `+`, and everything sits right of
the page. Each page keeps its own create dialog — the toolbar only renders
the trigger — so the testids the runtime spec and the capture script drive
stayed where they were.

Dashboards get the flow store's draft/publish split. The editor autosaves
`dashboard.draft.json` beside `dashboard.json`; `/view/{name}`, `bindings_for`
and `history_requirements` keep reading the published file, so a wall panel
sees an edit only once someone publishes it. `POST /dashboards/{name}/publish`
and `/discard` mirror the flow routes down to the version precondition and the
409, `GET /dashboards/{name}?draft=true` is what the editor asks for, and the
dock grows the same Publish button — which flushes a queued save first, so an
autosave in flight is not published around. Creating a dashboard still writes
the published file directly: an empty document on a panel is harmless, and it
keeps the store free of a never-published case.

"Publish all" is a checkmark in the toolbar, live only when something actually
has `has_draft`. A summary carries no version and publish needs the one it is
based on, so each document's detail is read immediately before its publish —
honest against a stale list, and no version-less backend path to maintain.
Failures are counted rather than swallowed: three of five fails says so and
names the three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
This commit is contained in:
2026-08-17 11:57:11 +02:00
co-authored by Claude Opus 5
parent 683c25b26d
commit 9351a86eec
14 changed files with 713 additions and 144 deletions
+3 -3
View File
@@ -16,9 +16,6 @@ should reopen it.
- INFRA: ensure that all the packages/ dependencies needed to run fluksio are available on arm to make this software runnable on e.g. raspbian
- INFRA: merge the philosophy statement at the beginning of vision.md into the rest of the document. Dissolve the decision dates and fold the decisions into a clean structure
- BUG/UI mobile friendly support is degraded: 1) toolbar in the "Flows" viewport extend mobile viewport width 2) position of nodes should never be static; always adjust such that there are as few as possible overlaps (of nodes and edge labels) and direction is left to right (desktop) or top to bottom (mobile) with a minimal (but clean) overall edge length 3) Dashboard view is not mobile friendly at all; as dashboard design is infeasible on mobile, render all widgets in a vertically stacked order. This allows to inspect each widget and make changes. Layout changes are not a feature on mobile 4) the home view is not responsive; all items shown there should re-order on mobile such that no scrollbars appear. Make sure the mobile support is anchored in the design such that future work does not break it
- FEAT/UX mirror the "publish" system from the flow editor to the dashboard; i.e. dashboards are always editable (edit mode) when not in the "read-only wallpanel" mode. Changes are made effective by clicking "Publish" in the toolbar (identical to flows).
- BUG/UI replace the search bar in flows/dashboard by a search icon which opens the search bar upon click. Also the create button should become a "+" icon button; both right aligned
- FEAT/UI introduce an "Publish all" (checkmark icon button) to the flow and dashboard overview which publishs all changes without having to access the individual flows
- FEAT/UI add a loading animation for the initial app load and when loading individual pages; make sure that elements e.g. in the home dashboard load independently to ensure a fast loading of the initial site but figures charts, tables, graph etc. follow after that
- FEAT/UI introduce a graph panel which renders at the top right next to the graph view (to make more use of the horizontal space) and which allows (de-) selecting flows to be excluded from the graph view or search for individual nodes where only the flows containing this node should be shown (like slicing the brain)
- FEAT/UI durations are written as a shortened number beside a fixed unit, so a slow run reads "1.2k ms" rather than "1.2 s". A duration formatter that steps the unit itself (µs/ms/s/min) would read better wherever `si` is followed by "ms"
@@ -165,6 +162,9 @@ as an em dash.
- PERF/UI: `ChartWidget` re-joins the whole table on every live value. Fine at IoT rates; at `HISTORY_CAP` × 5 series it should append into a ring buffer.
- CHORE/UI: opening edit mode on a dashboard whose widgets predate placement writes the migrated positions immediately, bumping the version once.
- CHORE/API: no backend test for the `WidgetDef` dtype validator or `columns`.
- FEAT/UI: `POST /dashboards/{name}/discard` has no button. The flow settings panel offers "discard draft"; the dashboard settings panel does not, so an unwanted edit can only be undone by hand or by publishing it.
- CHORE/API: creating a dashboard publishes it straight away (an empty document goes to the panels), while a new flow starts as a draft. Keeps `read`/`list` free of a never-published case, at the cost of the asymmetry.
- PERF/UI: "Publish all" reads each document's detail for the version its publish must match, so a click is 2N requests. A bulk endpoint, or a `version` on the summaries, would make it one.
### Flow editor follow-ups
+3
View File
@@ -213,6 +213,9 @@ Shares components with the admin view. See `docs/architecture/structure.canvas`
- [x] Layout by dragging and resizing (react-grid-layout), a grid size per
dashboard, and `/view/{name}` — a full-bleed route that loads neither the
editor nor the grid library, which is what a wall panel is pointed at
- [x] Draft and publish, as flows have it: the editor autosaves
`dashboard.draft.json` and a panel reads only the published document, so a
half-arranged page never reaches the wall until someone publishes it
- [ ] Per-device view
## Phase 5 — Website and docs
+58 -6
View File
@@ -26,6 +26,10 @@ class RenameRequest(BaseModel):
name: str
class PublishRequest(BaseModel):
version: int
def _apply_history_limits(store: Any, controller: Any) -> None:
"""Tell the engine how much past each charted message needs kept."""
controller.set_history_limits(store.history_requirements())
@@ -39,9 +43,12 @@ async def read_dashboards(store: DashboardStoreDep) -> Any:
@router.get("/{name}", response_model=DashboardDef)
async def read_dashboard(name: str, store: DashboardStoreDep) -> Any:
async def read_dashboard(
name: str, store: DashboardStoreDep, draft: bool = False
) -> Any:
"""What a panel shows, or with ``draft`` the copy the editor is on."""
try:
return await run_in_threadpool(store.read, name)
return await run_in_threadpool(store.read, name, draft)
except DashboardNotFound:
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
@@ -67,13 +74,46 @@ async def save_dashboard(
name: str,
body: DashboardDef,
store: DashboardStoreDep,
controller: FlowControllerDep,
) -> Any:
"""Replace a dashboard, refusing a save someone else has moved past."""
"""Save unpublished changes, refusing a save someone else has moved past.
This writes a draft: panels keep showing the published document until
someone publishes, so nothing here can change what a wall is displaying —
which is also why the engine's history limits are left alone.
"""
if body.name != name:
raise HTTPException(status_code=422, detail="The name in the body must match")
try:
saved = await run_in_threadpool(store.write, body, body.version)
saved = await run_in_threadpool(store.write_draft, body, body.version)
except DashboardNotFound:
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
except StaleVersion as exc:
raise HTTPException(
status_code=409,
detail={
"message": "Someone else saved this dashboard first",
"current_version": exc.current,
},
)
return saved
@router.post("/{name}/publish", response_model=DashboardDef)
async def publish_dashboard(
name: str,
body: PublishRequest,
store: DashboardStoreDep,
controller: FlowControllerDep,
) -> Any:
"""Put the unpublished changes on the panels."""
if not await run_in_threadpool(store.exists, name):
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
if not await run_in_threadpool(store.has_draft, name):
raise HTTPException(
status_code=400, detail=f"Dashboard '{name}' has no unpublished changes"
)
try:
published = await run_in_threadpool(store.publish, name, body.version)
except StaleVersion as exc:
raise HTTPException(
status_code=409,
@@ -83,7 +123,19 @@ async def save_dashboard(
},
)
await run_in_threadpool(_apply_history_limits, store, controller)
return saved
return published
@router.post("/{name}/discard", response_model=DashboardDef)
async def discard_dashboard_draft(name: str, store: DashboardStoreDep) -> Any:
"""Throw the unpublished changes away and go back to what is shown."""
if not await run_in_threadpool(store.exists, name):
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
if not await run_in_threadpool(store.has_draft, name):
raise HTTPException(
status_code=400, detail=f"Dashboard '{name}' has no unpublished changes"
)
return await run_in_threadpool(store.discard_draft, name)
@router.delete("/{name}", response_model=Message)
+90 -9
View File
@@ -6,9 +6,11 @@ reads across flows without being part of any of them, and a flow stays the
logic it was.
Stored beside the flows in the same git repository, under a directory the flow
listing ignores. No draft/publish split: nothing executes a dashboard, so edit
mode is its own staging area and the version counter is enough to stop two
clients overwriting each other.
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.
"""
from __future__ import annotations
@@ -189,6 +191,9 @@ class DashboardDef(BaseModel):
pages: list[PageDef] = Field(default_factory=list)
#: Bumped on every save; a save based on an older one is refused.
version: int = 1
#: Whether there are unpublished changes. Reported by the store on read,
#: never stored — the draft file's existence is the only record of it.
has_draft: bool = False
@field_validator("name")
@classmethod
@@ -207,6 +212,7 @@ class DashboardSummary(BaseModel):
title: str = ""
page_count: int = 0
widget_count: int = 0
has_draft: bool = False
class DashboardsPublic(BaseModel):
@@ -242,6 +248,14 @@ class DashboardStore:
def _file(self, name: str) -> Path:
return self.root / name / "dashboard.json"
def _draft_file(self, name: str) -> Path:
return self.root / name / "dashboard.draft.json"
@staticmethod
def _dump(defn: DashboardDef) -> str:
"""What goes on disk. ``has_draft`` is the file layout, not a field."""
return defn.model_dump_json(indent=2, exclude={"has_draft"})
def list(self) -> list[DashboardSummary]:
summaries = []
for path in sorted(self.root.glob("*/dashboard.json")):
@@ -255,6 +269,7 @@ class DashboardStore:
title=defn.title,
page_count=len(defn.pages),
widget_count=len(defn.widgets),
has_draft=self.has_draft(defn.name),
)
)
return summaries
@@ -262,16 +277,29 @@ class DashboardStore:
def exists(self, name: str) -> bool:
return self._file(name).exists()
def read(self, name: str) -> DashboardDef:
def has_draft(self, name: str) -> bool:
"""Are there unpublished changes to this dashboard?"""
return self._draft_file(name).exists()
def read(self, name: str, draft: bool = False) -> DashboardDef:
"""The published dashboard, or with ``draft`` the working copy."""
path = self._draft_file(name) if draft else self._file(name)
if not path.exists():
path = self._file(name)
if not path.exists():
raise DashboardNotFound(name)
return DashboardDef.model_validate_json(path.read_text())
return DashboardDef.model_validate_json(path.read_text()).model_copy(
update={"has_draft": self.has_draft(name)}
)
def write(
self, defn: DashboardDef, base_version: int | None = None
) -> DashboardDef:
"""Save, refusing a write based on a version someone has moved past."""
"""Publish a dashboard directly — what creating one does.
Every later edit goes through :meth:`write_draft`, so this only ever
writes the published file of a dashboard nobody has a draft of.
"""
with self._lock, self.flows._write_lock:
path = self._file(defn.name)
current = 0
@@ -280,18 +308,64 @@ class DashboardStore:
if base_version is not None and base_version != current:
raise StaleVersion(defn.name, current)
saved = defn.model_copy(update={"version": current + 1})
saved = defn.model_copy(update={"version": current + 1, "has_draft": False})
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(saved.model_dump_json(indent=2))
path.write_text(self._dump(saved))
self.flows._commit(f"Save dashboard '{defn.name}'")
return saved
def write_draft(
self, defn: DashboardDef, base_version: int | None = None
) -> DashboardDef:
"""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.
"""
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
if base_version is not None and base_version != current:
raise StaleVersion(defn.name, current)
saved = defn.model_copy(update={"version": current + 1, "has_draft": True})
path = self._draft_file(defn.name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(self._dump(saved))
self.flows._commit(f"Update draft of dashboard '{defn.name}'")
return saved
def publish(self, name: str, base_version: int | None = None) -> DashboardDef:
"""Promote the working copy to what the panels show."""
with self._lock, self.flows._write_lock:
current = self.read(name, draft=True)
if base_version is not None and base_version != current.version:
raise StaleVersion(name, current.version)
draft = self._draft_file(name)
if draft.exists():
self._file(name).write_text(self._dump(current))
draft.unlink()
self.flows._commit(f"Publish dashboard '{name}'")
return current.model_copy(update={"has_draft": False})
def discard_draft(self, name: str) -> DashboardDef:
"""Throw the unpublished changes away and go back to what is shown."""
with self._lock, self.flows._write_lock:
draft = self._draft_file(name)
if draft.exists():
draft.unlink()
self.flows._commit(f"Discard draft of dashboard '{name}'")
return self.read(name)
def delete(self, name: str) -> None:
path = self._file(name)
if not path.exists():
raise DashboardNotFound(name)
with self.flows._write_lock:
path.unlink()
self._draft_file(name).unlink(missing_ok=True)
try:
path.parent.rmdir()
except OSError:
@@ -306,7 +380,14 @@ class DashboardStore:
renamed = defn.model_copy(update={"name": new_name})
target = self._file(new_name)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(renamed.model_dump_json(indent=2))
target.write_text(self._dump(renamed))
# 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(name).unlink()
self._file(name).unlink()
try:
self._file(name).parent.rmdir()
+28
View File
@@ -59,6 +59,34 @@ def test_a_save_based_on_a_version_someone_moved_past_is_refused(
store.write(first, first.version)
def test_an_edit_reaches_a_panel_only_once_it_is_published(store: DashboardStore):
"""A wall panel reads the published file; the editor writes beside it."""
published = store.write(default_dashboard("house"))
draft = store.write_draft(
published.model_copy(update={"title": "Kitchen"}), published.version
)
assert store.has_draft("house")
assert store.read("house").title == published.title
assert store.read("house", draft=True).title == "Kitchen"
store.publish("house", draft.version)
assert store.read("house").title == "Kitchen"
assert not store.has_draft("house")
def test_discarding_leaves_what_is_published(store: DashboardStore):
published = store.write(default_dashboard("house"))
store.write_draft(
published.model_copy(update={"title": "Kitchen"}), published.version
)
assert store.discard_draft("house").title == published.title
assert not store.has_draft("house")
def test_deleting_and_renaming(store: DashboardStore):
store.write(default_dashboard("house"))
+27
View File
@@ -359,6 +359,11 @@ export const DashboardDef_InputSchema = {
type: 'integer',
title: 'Version',
default: 1
},
has_draft: {
type: 'boolean',
title: 'Has Draft',
default: false
}
},
type: 'object',
@@ -410,6 +415,11 @@ export const DashboardDef_OutputSchema = {
type: 'integer',
title: 'Version',
default: 1
},
has_draft: {
type: 'boolean',
title: 'Has Draft',
default: false
}
},
type: 'object',
@@ -438,6 +448,11 @@ export const DashboardSummarySchema = {
type: 'integer',
title: 'Widget Count',
default: 0
},
has_draft: {
type: 'boolean',
title: 'Has Draft',
default: false
}
},
type: 'object',
@@ -2501,6 +2516,18 @@ message it publishes — and is validated against the type below rather than
by a schema per class, because the whole set is small and closed.`
} as const;
export const app__api__routes__dashboards__PublishRequestSchema = {
properties: {
version: {
type: 'integer',
title: 'Version'
}
},
type: 'object',
required: ['version'],
title: 'PublishRequest'
} as const;
export const app__api__routes__dashboards__RenameRequestSchema = {
properties: {
name: {
+56 -2
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse } from './types.gen';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse } from './types.gen';
export class AlertsService {
/**
@@ -77,8 +77,10 @@ export class DashboardsService {
/**
* Read Dashboard
* What a panel shows, or with ``draft`` the copy the editor is on.
* @param data The data for the request.
* @param data.name
* @param data.draft
* @returns DashboardDef_Output Successful Response
* @throws ApiError
*/
@@ -89,6 +91,9 @@ export class DashboardsService {
path: {
name: data.name
},
query: {
draft: data.draft
},
errors: {
422: 'Validation Error'
}
@@ -118,7 +123,11 @@ export class DashboardsService {
/**
* Save Dashboard
* Replace a dashboard, refusing a save someone else has moved past.
* Save unpublished changes, refusing a save someone else has moved past.
*
* This writes a draft: panels keep showing the published document until
* someone publishes, so nothing here can change what a wall is displaying —
* which is also why the engine's history limits are left alone.
* @param data The data for the request.
* @param data.name
* @param data.requestBody
@@ -160,6 +169,51 @@ export class DashboardsService {
});
}
/**
* Publish Dashboard
* Put the unpublished changes on the panels.
* @param data The data for the request.
* @param data.name
* @param data.requestBody
* @returns DashboardDef_Output Successful Response
* @throws ApiError
*/
public static publishDashboard(data: DashboardsPublishDashboardData): CancelablePromise<DashboardsPublishDashboardResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/dashboards/{name}/publish',
path: {
name: data.name
},
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Discard Dashboard Draft
* Throw the unpublished changes away and go back to what is shown.
* @param data The data for the request.
* @param data.name
* @returns DashboardDef_Output Successful Response
* @throws ApiError
*/
public static discardDashboardDraft(data: DashboardsDiscardDashboardDraftData): CancelablePromise<DashboardsDiscardDashboardDraftResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/dashboards/{name}/discard',
path: {
name: data.name
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Rename Dashboard
* @param data The data for the request.
+21
View File
@@ -9,6 +9,10 @@ export type AlertsConfig = {
rules?: Array<Rule>;
};
export type app__api__routes__dashboards__PublishRequest = {
version: number;
};
export type app__api__routes__dashboards__RenameRequest = {
name: string;
};
@@ -132,6 +136,7 @@ export type DashboardDef_Input = {
canvas_height?: number;
pages?: Array<PageDef_Input>;
version?: number;
has_draft?: boolean;
};
/**
@@ -145,6 +150,7 @@ export type DashboardDef_Output = {
canvas_height?: number;
pages?: Array<PageDef_Output>;
version?: number;
has_draft?: boolean;
};
export type DashboardsPublic = {
@@ -160,6 +166,7 @@ export type DashboardSummary = {
title?: string;
page_count?: number;
widget_count?: number;
has_draft?: boolean;
};
export type DeadLetter = {
@@ -785,6 +792,7 @@ export type AlertsTestChannelResponse = (Message);
export type DashboardsReadDashboardsResponse = (DashboardsPublic);
export type DashboardsReadDashboardData = {
draft?: boolean;
name: string;
};
@@ -809,6 +817,19 @@ export type DashboardsDeleteDashboardData = {
export type DashboardsDeleteDashboardResponse = (Message);
export type DashboardsPublishDashboardData = {
name: string;
requestBody: app__api__routes__dashboards__PublishRequest;
};
export type DashboardsPublishDashboardResponse = (DashboardDef_Output);
export type DashboardsDiscardDashboardDraftData = {
name: string;
};
export type DashboardsDiscardDashboardDraftResponse = (DashboardDef_Output);
export type DashboardsRenameDashboardData = {
name: string;
requestBody: app__api__routes__dashboards__RenameRequest;
@@ -0,0 +1,191 @@
import { useMutation } from "@tanstack/react-query"
import { Check, Loader2, Plus, Search } from "lucide-react"
import { motion } from "motion/react"
import { useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import { DialogTrigger } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import useCustomToast from "@/hooks/useCustomToast"
import { transitions } from "@/lib/motion"
/** The icon buttons here and in the flow dock are the same touch target. */
const ICON = "size-11 text-muted-foreground md:size-8"
/**
* The bar over the flows and dashboards lists: find one, publish what is
* unpublished, or start a new one.
*
* Everything is an icon, right-aligned, so the list itself is what the page
* shows. The search field is folded away until it is asked for and folds back
* once it is empty and left alone, which keeps the row down to three targets.
*
* The create button is a `DialogTrigger`, so the page wrapping this in its own
* `Dialog` owns what asking for a name looks like.
*/
export function OverviewToolbar({
search,
onSearch,
searchLabel,
searchTestId,
createLabel,
createTestId,
draftCount,
publishing,
onPublishAll,
}: {
search: string
onSearch: (value: string) => void
searchLabel: string
searchTestId: string
createLabel: string
createTestId: string
/** How many of the listed documents have unpublished changes. */
draftCount: number
publishing: boolean
onPublishAll: () => void
}) {
const [open, setOpen] = useState(false)
const trigger = useRef<HTMLButtonElement>(null)
/** Escape puts the field away and hands focus back to the icon it came from. */
const collapse = () => {
onSearch("")
setOpen(false)
trigger.current?.focus()
}
return (
<div className="flex items-center justify-end gap-1">
{open ? (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: "16rem", opacity: 1 }}
transition={transitions.emphasized}
// Shrinks rather than pushing the buttons off a narrow screen.
className="min-w-0 overflow-hidden"
>
<Input
// The field exists because it was just asked for, so it takes focus.
autoFocus
value={search}
placeholder={searchLabel}
aria-label={searchLabel}
data-testid={searchTestId}
onChange={(event) => onSearch(event.target.value)}
onBlur={() => {
if (!search.trim()) setOpen(false)
}}
onKeyDown={(event) => {
if (event.key === "Escape") collapse()
}}
/>
</motion.div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={trigger}
variant="ghost"
size="icon"
className={ICON}
aria-label={searchLabel}
onClick={() => setOpen(true)}
>
<Search />
</Button>
</TooltipTrigger>
<TooltipContent>{searchLabel}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
{/* A disabled button gets no pointer events, so the tooltip that
explains why it is disabled needs a wrapper to hang on. */}
<span>
<Button
variant="ghost"
size="icon"
className={ICON}
disabled={draftCount === 0 || publishing}
onClick={onPublishAll}
aria-label="Publish all changes"
data-testid="publish-all"
>
{publishing ? <Loader2 className="animate-spin" /> : <Check />}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{draftCount === 0
? "Nothing unpublished"
: `Publish ${draftCount} unpublished change${draftCount === 1 ? "" : "s"}`}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className={ICON}
aria-label={createLabel}
data-testid={createTestId}
>
<Plus />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>{createLabel}</TooltipContent>
</Tooltip>
</div>
)
}
/**
* Publish several documents in turn, and say how many actually made it.
*
* Each one is its own request with its own version precondition, so one that
* someone else has moved past fails on its own rather than taking the batch
* with it — and the toast names the ones still unpublished instead of
* reporting a success that did not happen.
*/
export function usePublishAll(
publish: (name: string) => Promise<unknown>,
noun: string,
onDone: () => void,
) {
const { showSuccessToast, showErrorToast } = useCustomToast()
return useMutation({
mutationFn: async (names: string[]) => {
const failed: string[] = []
for (const name of names) {
try {
await publish(name)
} catch {
failed.push(name)
}
}
return failed
},
// Some of them may have landed even when others did not.
onSettled: onDone,
onSuccess: (failed, names) => {
if (failed.length)
showErrorToast(
`Published ${names.length - failed.length} of ${names.length}. Still unpublished: ${failed.join(", ")}`,
)
else
showSuccessToast(
`Published ${names.length} ${noun}${names.length === 1 ? "" : "s"}`,
)
},
})
}
@@ -59,7 +59,7 @@ import {
widgetsOf,
} from "./DashboardView"
import { DashboardPanel, WidgetPanel } from "./panels"
import { dashboardKeys, useSaveDashboard } from "./queries"
import { dashboardKeys, usePublishDashboard, useSaveDashboard } from "./queries"
import {
WIDGET_LABELS,
WIDGET_SIZES,
@@ -185,8 +185,12 @@ export function DashboardEditor({
const navigate = useNavigate()
const queryClient = useQueryClient()
const save = useSaveDashboard(dashboard.name)
const publish = usePublishDashboard(dashboard.name)
const { showErrorToast } = useCustomToast()
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
// The save that is already on its way, so a publish waits for it instead of
// going out with the version it is about to replace.
const inflight = useRef<Promise<void> | null>(null)
// The saved version is what the next save is based on; without following it
// the second save of a session is always a conflict.
const version = useRef(dashboard.version)
@@ -195,23 +199,43 @@ export function DashboardEditor({
version.current = dashboard.version
}, [dashboard.version])
const store = (next: Dashboard) => {
const request = save
.mutateAsync({ ...next, version: version.current })
.then((saved) => {
version.current = saved.version
})
.catch((error) => handleError.call(showErrorToast, error as ApiError))
inflight.current = request
return request
}
const commit = (next: Dashboard) => {
setDraft(next)
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => {
save.mutate(
{ ...next, version: version.current },
{
onSuccess: (saved) => {
version.current = saved.version
},
onError: (error) =>
handleError.call(showErrorToast, error as ApiError),
},
)
timer.current = null
void store(next)
}, AUTOSAVE_MS)
}
/** Send what is queued and wait for the server to have it. */
const flush = async () => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = null
await store(draft)
return
}
await inflight.current
}
/** Put the stored draft live. Publishing what is queued means saving first. */
const publishDashboard = async () => {
await flush()
publish.mutate(version.current ?? 1)
}
const remove = useMutation({
mutationFn: () => DashboardsService.deleteDashboard({ name: draft.name }),
onSuccess: () => {
@@ -221,6 +245,9 @@ export function DashboardEditor({
onError: handleError.bind(showErrorToast),
})
// What the server says about the stored document, not about the local edit:
// a save in flight is still "no unpublished changes" until it lands.
const hasDraft = Boolean(dashboard.has_draft)
const columns = columnsOf(draft)
const pages = pagesOf(draft)
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
@@ -478,10 +505,27 @@ export function DashboardEditor({
</span>
</TooltipTrigger>
<TooltipContent>
{save.isPending ? "Saving" : "All changes saved"}
{save.isPending
? "Saving"
: hasDraft
? "Saved — publish to put it on the panels"
: "All changes saved"}
</TooltipContent>
</Tooltip>
{hasDraft ? (
<Button
variant="outline"
size="sm"
className="h-11 shrink-0 rounded-full md:h-8"
onClick={() => void publishDashboard()}
disabled={publish.isPending}
data-testid="publish-dashboard"
>
{publish.isPending ? "Publishing…" : "Publish"}
</Button>
) : null}
<Separator orientation="vertical" className="mx-0.5 !h-5" />
</>
) : null}
+34 -6
View File
@@ -8,7 +8,9 @@ import {
export const dashboardKeys = {
all: ["dashboards"] as const,
detail: (name: string) => ["dashboards", name] as const,
/** The working copy and the published document are two different reads. */
detail: (name: string, draft = false) =>
["dashboards", name, draft ? "draft" : "published"] as const,
messages: ["messages"] as const,
history: (message: string) => ["messages", message, "history"] as const,
}
@@ -18,9 +20,15 @@ export const dashboardsQueryOptions = () => ({
queryFn: () => DashboardsService.readDashboards(),
})
export const dashboardQueryOptions = (name: string) => ({
queryKey: dashboardKeys.detail(name),
queryFn: () => DashboardsService.readDashboard({ name }),
/**
* A dashboard as a panel shows it, or with `draft` the copy being edited.
*
* A wall panel asks for the published one, which is the whole point of the
* split: nothing half-arranged reaches the wall until someone publishes.
*/
export const dashboardQueryOptions = (name: string, draft = false) => ({
queryKey: dashboardKeys.detail(name, draft),
queryFn: () => DashboardsService.readDashboard({ name, draft }),
})
/** Every message any flow declares — what a widget can be pointed at. */
@@ -34,14 +42,34 @@ export const messageHistoryQueryOptions = (message: string) => ({
queryFn: () => MessagesService.readMessageHistory({ name: message }),
})
/** Saving a dashboard, carrying the version it was based on. */
/** Saving a dashboard, carrying the version it was based on.
*
* This writes the draft; panels keep showing the published document.
*/
export function useSaveDashboard(name: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: DashboardDef_Input) =>
DashboardsService.saveDashboard({ name, requestBody: body }),
onSuccess: (saved) => {
queryClient.setQueryData(dashboardKeys.detail(name), saved)
queryClient.setQueryData(dashboardKeys.detail(name, true), saved)
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
},
})
}
/** Publish the unpublished changes, which is what puts them on the panels. */
export function usePublishDashboard(name: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (version: number) =>
DashboardsService.publishDashboard({
name,
requestBody: { version },
}),
onSuccess: (published) => {
queryClient.setQueryData(dashboardKeys.detail(name, true), published)
queryClient.setQueryData(dashboardKeys.detail(name), published)
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
},
})
@@ -23,7 +23,9 @@ function DashboardRoute() {
const { edit } = Route.useSearch()
// Widgets read live values; the canvas shell has no socket of its own.
useFlowSocket()
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
// The working copy: an edit is the editor's until it is published, so what
// a wall panel is showing right now stays untouched by this session.
const { data: dashboard } = useQuery(dashboardQueryOptions(name, true))
if (!dashboard) return null
@@ -1,9 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { LayoutDashboard, Plus } from "lucide-react"
import { LayoutDashboard } from "lucide-react"
import { useState } from "react"
import { DashboardsService } from "@/client"
import {
OverviewToolbar,
usePublishAll,
} from "@/components/Common/OverviewToolbar"
import {
dashboardKeys,
dashboardsQueryOptions,
@@ -16,7 +20,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import useCustomToast from "@/hooks/useCustomToast"
@@ -50,6 +53,23 @@ 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.
const publishAll = usePublishAll(
async (dashboard) => {
const current = await DashboardsService.readDashboard({
name: dashboard,
draft: true,
})
return DashboardsService.publishDashboard({
name: dashboard,
requestBody: { version: current.version ?? 1 },
})
},
"dashboard",
() => queryClient.invalidateQueries({ queryKey: dashboardKeys.all }),
)
// The store only accepts this shape, so say so before the request does.
const slug = name
.trim()
@@ -59,6 +79,9 @@ function Dashboards() {
const dashboards = (data?.data ?? []).filter((dashboard) =>
`${dashboard.name} ${dashboard.title ?? ""}`.toLowerCase().includes(needle),
)
const drafts = (data?.data ?? [])
.filter((dashboard) => dashboard.has_draft)
.map((dashboard) => dashboard.name)
return (
<div className="grid gap-6">
@@ -69,22 +92,18 @@ function Dashboards() {
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
value={search}
placeholder="Search dashboards"
aria-label="Search dashboards"
className="max-w-xs"
data-testid="search-dashboards"
onChange={(event) => setSearch(event.target.value)}
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary" data-testid="new-dashboard">
<Plus />
Create
</Button>
</DialogTrigger>
<OverviewToolbar
search={search}
onSearch={setSearch}
searchLabel="Search dashboards"
searchTestId="search-dashboards"
createLabel="New dashboard"
createTestId="new-dashboard"
draftCount={drafts.length}
publishing={publishAll.isPending}
onPublishAll={() => publishAll.mutate(drafts)}
/>
<DialogContent>
<form
className="grid gap-4"
@@ -119,7 +138,6 @@ function Dashboards() {
</form>
</DialogContent>
</Dialog>
</div>
{dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
@@ -140,6 +158,11 @@ function Dashboards() {
<span className="flex items-center gap-2">
<LayoutDashboard className="size-4 text-muted-foreground" />
{dashboard.title || dashboard.name}
{dashboard.has_draft ? (
<span className="size-1.5 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
</span>
<span className="text-sm text-muted-foreground">
{dashboard.widget_count} widget
+33 -18
View File
@@ -1,9 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { Plus, Workflow } from "lucide-react"
import { Workflow } from "lucide-react"
import { useState } from "react"
import { FlowsService } from "@/client"
import {
OverviewToolbar,
usePublishAll,
} from "@/components/Common/OverviewToolbar"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
import { Button } from "@/components/ui/button"
import {
@@ -13,7 +17,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import useCustomToast from "@/hooks/useCustomToast"
@@ -50,6 +53,20 @@ 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.
const publishAll = usePublishAll(
async (flow) => {
const detail = await FlowsService.readFlow({ name: flow })
return FlowsService.publishFlow({
name: flow,
requestBody: { version: detail.definition.version ?? 1 },
})
},
"flow",
() => queryClient.invalidateQueries({ queryKey: flowKeys.all }),
)
const slug = name
.trim()
.toLowerCase()
@@ -58,6 +75,9 @@ function Flows() {
const flows = (data?.data ?? []).filter((flow) =>
`${flow.name} ${flow.title ?? ""}`.toLowerCase().includes(needle),
)
const drafts = (data?.data ?? [])
.filter((flow) => flow.has_draft)
.map((flow) => flow.name)
return (
<div className="grid gap-6">
@@ -69,22 +89,18 @@ function Flows() {
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
value={search}
placeholder="Search flows"
aria-label="Search flows"
className="max-w-xs"
data-testid="search-flows"
onChange={(event) => setSearch(event.target.value)}
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary" data-testid="new-flow">
<Plus />
Create
</Button>
</DialogTrigger>
<OverviewToolbar
search={search}
onSearch={setSearch}
searchLabel="Search flows"
searchTestId="search-flows"
createLabel="New flow"
createTestId="new-flow"
draftCount={drafts.length}
publishing={publishAll.isPending}
onPublishAll={() => publishAll.mutate(drafts)}
/>
<DialogContent>
<form
className="grid gap-4"
@@ -119,7 +135,6 @@ function Flows() {
</form>
</DialogContent>
</Dialog>
</div>
{flows.length === 0 ? (
<p className="text-sm text-muted-foreground">