From 9351a86eec0279bf1c0c91aaaa8212844aef7d09 Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 17 Aug 2026 11:57:11 +0200 Subject: [PATCH] Overviews: icon toolbar, dashboard drafts, publish all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB --- NOTEPAD.md | 6 +- ROADMAP.md | 3 + backend/app/api/routes/dashboards.py | 64 +++++- backend/app/flow/dashboards.py | 101 ++++++++- backend/tests/flow/test_dashboards.py | 28 +++ frontend/src/client/schemas.gen.ts | 27 +++ frontend/src/client/sdk.gen.ts | 58 +++++- frontend/src/client/types.gen.ts | 21 ++ .../src/components/Common/OverviewToolbar.tsx | 191 ++++++++++++++++++ .../components/Dashboard/DashboardEditor.tsx | 68 +++++-- frontend/src/components/Dashboard/queries.ts | 40 +++- .../src/routes/_canvas/dashboards/$name.tsx | 4 +- .../src/routes/_layout/dashboards/index.tsx | 127 +++++++----- frontend/src/routes/_layout/flows/index.tsx | 119 ++++++----- 14 files changed, 713 insertions(+), 144 deletions(-) create mode 100644 frontend/src/components/Common/OverviewToolbar.tsx diff --git a/NOTEPAD.md b/NOTEPAD.md index 270deea..dbdf613 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 326b3c0..8415d48 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/backend/app/api/routes/dashboards.py b/backend/app/api/routes/dashboards.py index fb3e852..839e430 100644 --- a/backend/app/api/routes/dashboards.py +++ b/backend/app/api/routes/dashboards.py @@ -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) diff --git a/backend/app/flow/dashboards.py b/backend/app/flow/dashboards.py index bddca63..fc6eb84 100644 --- a/backend/app/flow/dashboards.py +++ b/backend/app/flow/dashboards.py @@ -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: - path = self._file(name) + 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() diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py index 7390a28..6cc7cd8 100644 --- a/backend/tests/flow/test_dashboards.py +++ b/backend/tests/flow/test_dashboards.py @@ -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")) diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index dde6823..5944d47 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -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: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index bc65f32..93174e4 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -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 { + 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 { + 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. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 26d1b76..d58ddf6 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -9,6 +9,10 @@ export type AlertsConfig = { rules?: Array; }; +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; version?: number; + has_draft?: boolean; }; /** @@ -145,6 +150,7 @@ export type DashboardDef_Output = { canvas_height?: number; pages?: Array; 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; diff --git a/frontend/src/components/Common/OverviewToolbar.tsx b/frontend/src/components/Common/OverviewToolbar.tsx new file mode 100644 index 0000000..c1aff7d --- /dev/null +++ b/frontend/src/components/Common/OverviewToolbar.tsx @@ -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(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 ( +
+ {open ? ( + + onSearch(event.target.value)} + onBlur={() => { + if (!search.trim()) setOpen(false) + }} + onKeyDown={(event) => { + if (event.key === "Escape") collapse() + }} + /> + + ) : ( + + + + + {searchLabel} + + )} + + + + {/* A disabled button gets no pointer events, so the tooltip that + explains why it is disabled needs a wrapper to hang on. */} + + + + + + {draftCount === 0 + ? "Nothing unpublished" + : `Publish ${draftCount} unpublished change${draftCount === 1 ? "" : "s"}`} + + + + + + + + + + {createLabel} + +
+ ) +} + +/** + * 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, + 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"}`, + ) + }, + }) +} diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 2658c5d..26abd35 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -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 | 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 | 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({ - {save.isPending ? "Saving" : "All changes saved"} + {save.isPending + ? "Saving" + : hasDraft + ? "Saved — publish to put it on the panels" + : "All changes saved"} + {hasDraft ? ( + + ) : null} + ) : null} diff --git a/frontend/src/components/Dashboard/queries.ts b/frontend/src/components/Dashboard/queries.ts index 616ce7e..a37fe4d 100644 --- a/frontend/src/components/Dashboard/queries.ts +++ b/frontend/src/components/Dashboard/queries.ts @@ -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 }) }, }) diff --git a/frontend/src/routes/_canvas/dashboards/$name.tsx b/frontend/src/routes/_canvas/dashboards/$name.tsx index ab3bb80..d121a8d 100644 --- a/frontend/src/routes/_canvas/dashboards/$name.tsx +++ b/frontend/src/routes/_canvas/dashboards/$name.tsx @@ -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 diff --git a/frontend/src/routes/_layout/dashboards/index.tsx b/frontend/src/routes/_layout/dashboards/index.tsx index de7ff0b..ff3db47 100644 --- a/frontend/src/routes/_layout/dashboards/index.tsx +++ b/frontend/src/routes/_layout/dashboards/index.tsx @@ -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 (
@@ -69,57 +92,52 @@ function Dashboards() {

-
- setSearch(event.target.value)} + + publishAll.mutate(drafts)} /> - - - - - -
{ - event.preventDefault() - if (slug) create.mutate(slug) - }} - > - - New dashboard - - Name it after the panel it will hang on, or what it shows. - - - setName(event.target.value)} - /> - - - -
-
-
-
+ +
{ + event.preventDefault() + if (slug) create.mutate(slug) + }} + > + + New dashboard + + Name it after the panel it will hang on, or what it shows. + + + setName(event.target.value)} + /> + + + +
+
+ {dashboards.length === 0 ? (

@@ -140,6 +158,11 @@ function Dashboards() { {dashboard.title || dashboard.name} + {dashboard.has_draft ? ( + + Unpublished changes + + ) : null} {dashboard.widget_count} widget diff --git a/frontend/src/routes/_layout/flows/index.tsx b/frontend/src/routes/_layout/flows/index.tsx index ae89e0a..2bb3289 100644 --- a/frontend/src/routes/_layout/flows/index.tsx +++ b/frontend/src/routes/_layout/flows/index.tsx @@ -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 (

@@ -69,57 +89,52 @@ function Flows() {

-
- setSearch(event.target.value)} + + publishAll.mutate(drafts)} /> - - - - - -
{ - event.preventDefault() - if (NAME.test(slug)) create.mutate(slug) - }} - > - - New flow - - Flows are small on purpose. Name this one after what it does. - - - setName(event.target.value)} - /> - - - -
-
-
-
+ +
{ + event.preventDefault() + if (NAME.test(slug)) create.mutate(slug) + }} + > + + New flow + + Flows are small on purpose. Name this one after what it does. + + + setName(event.target.value)} + /> + + + +
+
+ {flows.length === 0 ? (