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:
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,57 +92,52 @@ 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}>
|
||||
<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)}
|
||||
/>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" data-testid="new-dashboard">
|
||||
<Plus />
|
||||
Create
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (slug) create.mutate(slug)
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New dashboard</DialogTitle>
|
||||
<DialogDescription>
|
||||
Name it after the panel it will hang on, or what it shows.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder="kitchen"
|
||||
aria-label="New dashboard name"
|
||||
autoComplete="off"
|
||||
data-testid="new-dashboard-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!slug || create.isPending}
|
||||
data-testid="create-dashboard"
|
||||
>
|
||||
Create dashboard
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<DialogContent>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (slug) create.mutate(slug)
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New dashboard</DialogTitle>
|
||||
<DialogDescription>
|
||||
Name it after the panel it will hang on, or what it shows.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder="kitchen"
|
||||
aria-label="New dashboard name"
|
||||
autoComplete="off"
|
||||
data-testid="new-dashboard-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!slug || create.isPending}
|
||||
data-testid="create-dashboard"
|
||||
>
|
||||
Create dashboard
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{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
|
||||
|
||||
@@ -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,57 +89,52 @@ 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}>
|
||||
<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)}
|
||||
/>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" data-testid="new-flow">
|
||||
<Plus />
|
||||
Create
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (NAME.test(slug)) create.mutate(slug)
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New flow</DialogTitle>
|
||||
<DialogDescription>
|
||||
Flows are small on purpose. Name this one after what it does.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder="heating"
|
||||
aria-label="New flow name"
|
||||
autoComplete="off"
|
||||
data-testid="new-flow-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!NAME.test(slug) || create.isPending}
|
||||
data-testid="create-flow"
|
||||
>
|
||||
Create flow
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<DialogContent>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (NAME.test(slug)) create.mutate(slug)
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New flow</DialogTitle>
|
||||
<DialogDescription>
|
||||
Flows are small on purpose. Name this one after what it does.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder="heating"
|
||||
aria-label="New flow name"
|
||||
autoComplete="off"
|
||||
data-testid="new-flow-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!NAME.test(slug) || create.isPending}
|
||||
data-testid="create-flow"
|
||||
>
|
||||
Create flow
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{flows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user