From 3ae5255617cbff5cff25217b429904f191f5f728 Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 2 Nov 2026 22:58:41 +0100 Subject: [PATCH] Rename UI: superuser Key row and rename dialog for flows and dashboards (live plan with rewritten documents, counts, refusals and warnings, typed confirmation, what the drain waits for and calling it off); applyRename and the socket's renamed remap the live store and refetch at unchanged URLs, editors whose document moved reload so neither undo nor a queued save restores old bindings, a reconnect checks again and its snapshot replaces values; a retry toast for the rename's 503s, autosaves send themselves again; dashboard saves are based on their own versions only; Playwright rename spec, liveStore check, editor and dashboard docs --- docs/interface/dashboards.md | 8 + docs/interface/flow-editor.md | 31 ++ .../components/Dashboard/DashboardEditor.tsx | 61 ++- frontend/src/components/Dashboard/panels.tsx | 19 + frontend/src/components/Dashboard/queries.ts | 1 + .../src/components/Flow/EdgeInspector.tsx | 5 +- frontend/src/components/Flow/FlowEditor.tsx | 56 ++- frontend/src/components/Flow/FlowPanel.tsx | 19 + frontend/src/components/Flow/NodePanel.tsx | 15 +- frontend/src/components/Flow/RenameDialog.tsx | 458 ++++++++++++++++++ frontend/src/components/Flow/SidePanel.tsx | 26 +- .../src/components/Flow/liveStore.check.ts | 48 ++ frontend/src/components/Flow/liveStore.ts | 49 +- frontend/src/components/Flow/queries.ts | 37 +- frontend/src/components/Flow/useFlowSocket.ts | 91 +++- frontend/src/components/Health/FlowTable.tsx | 5 +- frontend/src/main.tsx | 8 +- .../src/routes/_canvas/dashboards/$id.tsx | 8 +- frontend/src/utils.ts | 26 +- frontend/tests/rename.spec.ts | 380 +++++++++++++++ 20 files changed, 1313 insertions(+), 38 deletions(-) create mode 100644 frontend/src/components/Flow/RenameDialog.tsx create mode 100644 frontend/src/components/Flow/liveStore.check.ts create mode 100644 frontend/tests/rename.spec.ts diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 1af4931..1b00597 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -262,6 +262,14 @@ showing the dashboard all use it. A dashboard from before ids were introduced kept its key as its id, while one created now gets an opaque id such as `d_k3q7m2xa`. The tab shows the title. +A superuser renames the key with the pencil on the **Key** row, after typing +the current key to confirm. Only the name changes: the id, links, the panels +showing the dashboard and its widgets' bindings stay. Scripts and API calls +that name the dashboard by its old key have to be updated. Other editors open +on it reload with the new key. Renaming a flow's key rewrites the bindings of +every dashboard that reads it, see +[Renaming the key](flow-editor.md#renaming-the-key). + ### Look and theme **Fluksio** is the default look. **Material** uses flat tonal cards; **Glass** diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index cb89cff..a4b04ea 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -126,6 +126,37 @@ canvas show the title, or the key when there is no title. For live inputs, **Publish this value now** sends the starting value immediately. Changing a stored starting value alone does not replace an existing message. +### Renaming the key + +A superuser renames a flow's key with the pencil on the **Key** row. Type the +new key and the dialog shows what the rename would change before anything +does: the flows and dashboards it rewrites, how much history, live state and +queued work moves, and anything that refuses it, such as a key already taken, +a queued or running run, or a flow declared in code (rename that one in its +repository and sync it). Type the current key to confirm. + +Everything named under the key moves to the new one. Messages `old.x` become +`new.x` in every flow and dashboard bound to them, published and draft, along +with their history, live values, queued work and alert channels. The id, the +editor's address, hook URLs and device credentials stay as they were. + +What lives outside Fluksio keeps the old name and has to be updated by hand: +calls to `/messages/old.x`, your own scripts, CLI, SDK and MCP calls that pass +the key, and bindings written by hand. A batch flow's `old_results` dashboard +keeps its name. + +The whole engine pauses during a flow rename. It first waits for the work +running at that moment to finish, as long as that takes, and the dialog lists +what it is waiting for. Until the rename starts changing things you can call it +off, and nothing changes. Meanwhile pages keep reading, but changes anywhere +are turned away with a note to try again in a few seconds. Autosaves retry by +themselves. + +Open editors, panels and lists follow the rename at the address they already +have. An editor whose flow or dashboard was rewritten reloads with the renamed +version, so neither undo nor a save still on its way can bring the old names +back. Edits it had not saved yet are dropped, and it says so. + ## Inspecting an edge Click an edge to see its latest payload, timestamp and recent numeric history. diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index e77f8a0..8820d19 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -33,6 +33,8 @@ import { } from "@/components/Common/Dock" import { devicesQueryOptions } from "@/components/Devices/queries" import { CanvasTitle } from "@/components/Flow/CanvasTitle" +import { RETRY_LATER } from "@/components/Flow/queries" +import { listenRenames } from "@/components/Flow/useFlowSocket" import { Button } from "@/components/ui/button" import { Tooltip, @@ -42,10 +44,11 @@ import { import useCustomToast from "@/hooks/useCustomToast" import { useIsMobile } from "@/hooks/useMobile" import { slideUp, transitions } from "@/lib/motion" +import { notify } from "@/lib/notificationStore" import { appPath } from "@/lib/portal" import { useShortcuts } from "@/lib/shortcuts" import { cn } from "@/lib/utils" -import { handleError } from "@/utils" +import { handleError, retryLater } from "@/utils" import { copyWidget, useCopiedWidget } from "./clipboard" import { DashboardPalette } from "./DashboardPalette" import { @@ -380,9 +383,12 @@ const same = (a: Layout, b: Layout) => export function DashboardEditor({ dashboard, edit, + onReload, }: { dashboard: Dashboard edit: boolean + /** Take the server's document over this one, by remounting. */ + onReload: () => void }) { const [draft, setDraft] = useState(dashboard) // What the API, the routes and the devices know it by; never edited. @@ -416,13 +422,41 @@ export function DashboardEditor({ // 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. + // the second save of a session is always a conflict. Only this editor's own + // saves move it: taking a refetched version would let the next save of an + // older draft write over whatever moved the document, a rename included. const version = useRef(dashboard.version) const history = useRef({ past: [], future: [], at: 0 }) + // The newest local document and whether the server has it yet. + const latest = useRef(dashboard) + const unsaved = useRef(false) - useEffect(() => { - version.current = dashboard.version - }, [dashboard.version]) + // As in the flow editor: a rename, or a socket back after one may have gone + // by, and a server copy newer than the one this draft is based on. That + // copy replaces this one, undo history and queued save included. + useEffect( + () => + listenRenames((event) => { + if (event.type === "rename_waiting") return + // Read directly: a refetch through the cache is cancelled by the + // invalidations the same rename sets off. + DashboardsService.readDashboard({ dashboardId: id, draft: true }) + .then((fresh) => { + if ((fresh.version ?? 0) <= (version.current ?? 0)) return + if (event.type !== "renamed" && unsaved.current) return + if (event.type === "renamed" && !event.local) + notify( + `${event.old} is now ${event.new}. The dashboard reloaded with it.${unsaved.current ? " Edits not yet saved were dropped." : ""}`, + "info", + { id: `rename:${event.op}` }, + ) + queryClient.setQueryData(dashboardKeys.detail(id, true), fresh) + onReload() + }) + .catch(() => {}) + }), + [id, queryClient, onReload], + ) useEffect(() => { if (selected) setFace(selected) @@ -433,14 +467,28 @@ export function DashboardEditor({ .mutateAsync({ ...next, version: version.current }) .then((saved) => { version.current = saved.version + if (latest.current === next) unsaved.current = false + }) + .catch((error) => { + // Turned away while a rename runs: the newest draft goes again + // shortly, unless an edit has queued it already. + if (!retryLater(error, true)) { + handleError.call(showErrorToast, error as ApiError) + } else if (!timer.current) { + timer.current = setTimeout(() => { + timer.current = null + void store(latest.current) + }, RETRY_LATER) + } }) - .catch((error) => handleError.call(showErrorToast, error as ApiError)) inflight.current = request return request } /** Put a dashboard on the canvas and on its way to the server. */ const apply = (next: Dashboard) => { + latest.current = next + unsaved.current = true setDraft(next) if (timer.current) clearTimeout(timer.current) timer.current = setTimeout(() => { @@ -1044,6 +1092,7 @@ export function DashboardEditor({ onChange={(changes) => commit({ ...draft, ...changes })} onDelete={() => remove.mutate()} onClose={() => setSettingsOpen(false)} + flush={flush} /> ) => void onDelete: () => void onClose: () => void + /** Sends the queued edit, before a rename plans on the stored document. */ + flush: () => Promise }) { const [confirmOpen, setConfirmOpen] = useState(false) + const [renameOpen, setRenameOpen] = useState(false) + const { user } = useAuth() const canvas = canvasOf(dashboard) const round = isRound(dashboard) const theme = settingOf(dashboard, "theme") @@ -1987,6 +1994,9 @@ export function DashboardPanel({ name={dashboard.name} id={dashboard.id!} help="The key is the dashboard's name. The id never changes: this page's address, the view link, devices and the API use it." + onRename={ + user?.is_superuser ? () => setRenameOpen(true) : undefined + } /> + + diff --git a/frontend/src/components/Dashboard/queries.ts b/frontend/src/components/Dashboard/queries.ts index 59058cd..84ae279 100644 --- a/frontend/src/components/Dashboard/queries.ts +++ b/frontend/src/components/Dashboard/queries.ts @@ -49,6 +49,7 @@ export const messageHistoryQueryOptions = (message: string) => ({ export function useSaveDashboard(id: string) { const queryClient = useQueryClient() return useMutation({ + meta: { autosave: true }, mutationFn: (body: DashboardDef_Input) => DashboardsService.saveDashboard({ dashboardId: id, requestBody: body }), onSuccess: (saved) => { diff --git a/frontend/src/components/Flow/EdgeInspector.tsx b/frontend/src/components/Flow/EdgeInspector.tsx index d04f32d..bcfb358 100644 --- a/frontend/src/components/Flow/EdgeInspector.tsx +++ b/frontend/src/components/Flow/EdgeInspector.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button" import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover" import useCustomToast from "@/hooks/useCustomToast" import { ago } from "@/lib/utils" +import { retryLater } from "@/utils" import { displayName } from "./deriveEdges" import { useLiveValue } from "./liveStore" import { MessageSparkline } from "./MessageSparkline" @@ -71,7 +72,9 @@ export function EdgeInspector({ nodeId: edge?.sourceId ?? "", requestBody: { values: { [edge?.message ?? ""]: value } }, }), - onError: () => showErrorToast("The message could not be sent again."), + onError: (error) => + retryLater(error) || + showErrorToast("The message could not be sent again."), }) if (!edge) return null diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index 57cd29d..879a732 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -58,6 +58,7 @@ import { notify } from "@/lib/notificationStore" import { safeStorage } from "@/lib/safeStorage" import { inCodeEditor, useShortcuts } from "@/lib/shortcuts" import { cn } from "@/lib/utils" +import { retryLater } from "@/utils" import { CanvasTitle } from "./CanvasTitle" import { CommandPalette } from "./CommandPalette" import { @@ -101,7 +102,7 @@ import { useDiscardDraft, usePublish, } from "./queries" -import { useFlowSocket } from "./useFlowSocket" +import { listenRenames, useFlowSocket } from "./useFlowSocket" const nodeTypes = { flow: FlowNode, [ENDPOINT_TYPE]: EndpointNode } @@ -288,6 +289,8 @@ function FlowEditorInner({ conflict, resolveConflict, adopt, + version: savedVersion, + dirty, mutation: saving, } = useAutosave(flowId) const publish = usePublish(flowId) @@ -338,6 +341,40 @@ function FlowEditorInner({ const latest = useRef(detail.definition) const history = useRef({ past: [], future: [], at: 0 }) + // A rename rewrote documents, here or in another browser, or the socket is + // back and one may have gone by. A document this canvas holds an older + // version of is read again at the same address: the undo history and any + // queued save go with the old copy, so none of it is written back over the + // renamed bindings. Saving it would be refused anyway, as stale. + useEffect( + () => + listenRenames((event) => { + if (event.type === "rename_waiting") return + if (event.type === "renamed" && event.kind !== "flow") return + // Read directly: a refetch through the cache is cancelled by the + // invalidations the same rename sets off. + FlowsService.readFlow({ flowId }) + .then((fresh) => { + const based = savedVersion() ?? latest.current.version ?? 0 + if ((fresh.definition.version ?? 0) <= based) return + const unsaved = dirty() + // Only a rename outweighs an edit not yet saved: anything else + // that moved the document meets it as a conflict on the next save. + if (event.type !== "renamed" && unsaved) return + if (event.type === "renamed" && !event.local) + notify( + `${event.old} is now ${event.new}. The canvas reloaded with the renamed bindings.${unsaved ? " Edits not yet saved were dropped." : ""}`, + "info", + { id: `rename:${event.op}` }, + ) + queryClient.setQueryData(flowKeys.detail(flowId), fresh) + onReload() + }) + .catch(() => {}) + }), + [flowId, queryClient, savedVersion, dirty, onReload], + ) + /** Put a document on the canvas and on its way to the server. */ const applyDoc = useCallback( (next: FlowDef_Input) => { @@ -881,7 +918,8 @@ function FlowEditorInner({ ), ) }, - onError: () => + onError: (error) => + retryLater(error) || showErrorToast("The flow could not run. Check the node errors."), }) @@ -907,7 +945,9 @@ function FlowEditorInner({ queryClient.setQueryData(flowKeys.detail(flowId), detail) queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true }) }, - onError: () => showErrorToast("The flow could not be started or stopped."), + onError: (error) => + retryLater(error) || + showErrorToast("The flow could not be started or stopped."), }) const pauseMutation = useMutation({ @@ -916,14 +956,16 @@ function FlowEditorInner({ ? FlowsService.pauseFlow({ flowId }) : FlowsService.resumeFlow({ flowId }), // The engine answers with a flow_paused event, which is what the dock reads. - onError: () => showErrorToast("The flow could not be paused."), + onError: (error) => + retryLater(error) || showErrorToast("The flow could not be paused."), }) const stepMutation = useMutation({ mutationFn: () => FlowsService.stepFlow({ flowId }), // Says which node ran, or that nothing was held back — both are answers. onSuccess: (result) => showSuccessToast(result.message), - onError: () => showErrorToast("The flow could not be stepped."), + onError: (error) => + retryLater(error) || showErrorToast("The flow could not be stepped."), }) const deleteMutation = useMutation({ @@ -939,7 +981,8 @@ function FlowEditorInner({ setFlowPanelOpen(false) navigate({ to: "/flows", replace: true }) }, - onError: () => showErrorToast("The flow could not be deleted."), + onError: (error) => + retryLater(error) || showErrorToast("The flow could not be deleted."), }) const sourceMutation = useMutation({ @@ -1553,6 +1596,7 @@ function FlowEditorInner({ onToggleEnabled={(next) => enableMutation.mutate(next)} hasDraft={detail.has_draft ?? false} onClose={() => setFlowPanelOpen(false)} + flush={flush} /> void onClose: () => void + /** Sends the queued edit, before a rename plans on the stored document. */ + flush: () => Promise }) { const [confirmOpen, setConfirmOpen] = useState(false) const [withRuns, setWithRuns] = useState(false) + const [renameOpen, setRenameOpen] = useState(false) + const { user } = useAuth() return ( <> @@ -88,6 +95,9 @@ export function FlowPanel({ name={definition.name} id={flowId} help="The key names the flow's messages. The id never changes: the address of this page, its hook URLs and the API use it." + onRename={ + user?.is_superuser ? () => setRenameOpen(true) : undefined + } /> + + diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index fe25ece..28bbb6e 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -61,7 +61,7 @@ import useAuth from "@/hooks/useAuth" import useCustomToast from "@/hooks/useCustomToast" import { inCodeEditor, useShortcuts } from "@/lib/shortcuts" import { cn } from "@/lib/utils" -import { handleError } from "@/utils" +import { handleError, retryLater } from "@/utils" import { qualify } from "./deriveEdges" import { DtypeValue } from "./FlowBoundary" import { @@ -1710,14 +1710,16 @@ function SharingSection({ requestBody: { lib_name: libName }, }), onSuccess: done, - onError: () => + onError: (error) => + retryLater(error) || showErrorToast("That name is taken, or is not a valid name."), }) const unshare = useMutation({ mutationFn: () => FlowsService.unshareNode({ flowId, nodeId: node.id }), onSuccess: done, - onError: () => showErrorToast("The node could not be unshared."), + onError: (error) => + retryLater(error) || showErrorToast("The node could not be unshared."), }) if (shared) { @@ -1957,7 +1959,9 @@ function StoredStateSection({ nodeType }: { nodeType: string }) { setConfirming(false) queryClient.invalidateQueries({ queryKey }) }, - onError: () => showErrorToast("The stored state could not be cleared."), + onError: (error) => + retryLater(error) || + showErrorToast("The stored state could not be cleared."), }) const forget = useMutation({ mutationFn: (key: string) => @@ -1966,7 +1970,8 @@ function StoredStateSection({ nodeType }: { nodeType: string }) { showSuccessToast(`Deleted '${key}'`) queryClient.invalidateQueries({ queryKey }) }, - onError: () => showErrorToast("The key could not be deleted."), + onError: (error) => + retryLater(error) || showErrorToast("The key could not be deleted."), }) if (!data) return null diff --git a/frontend/src/components/Flow/RenameDialog.tsx b/frontend/src/components/Flow/RenameDialog.tsx new file mode 100644 index 0000000..2374bec --- /dev/null +++ b/frontend/src/components/Flow/RenameDialog.tsx @@ -0,0 +1,458 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { CircleAlert, TriangleAlert } from "lucide-react" +import { useEffect, useId, useState } from "react" + +import { + ApiError, + DashboardsService, + FlowsService, + type RenamePlan, + type RenameRequest, +} from "@/client" +import { summaryQueryOptions } from "@/components/Health/queries" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { LoadingButton } from "@/components/ui/loading-button" +import { notify } from "@/lib/notificationStore" +import { si } from "@/lib/utils" +import { applyRename, listenRenames } from "./useFlowSocket" + +type Kind = "flow" | "dashboard" + +/** The caption above a group, as a panel section has it. */ +const SECTION = + "text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground" + +/** How long typing rests before the plan is asked for. */ +const PLAN_DELAY = 300 + +/** + * One key per attempt, so the answer to a request sent twice is the first + * one's. `crypto.randomUUID` is missing on a page served over plain HTTP. + */ +function requestKey(): string { + if (typeof crypto?.randomUUID === "function") return crypto.randomUUID() + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` +} + +/** What a refused rename answers: the reason, and the plan as it is now. */ +function errorOf(error: unknown): { + code: string + detail: string + plan?: RenamePlan +} { + const body = (error instanceof ApiError ? error.body : null) as { + code?: string + detail?: unknown + plan?: RenamePlan + } | null + return { + code: body?.code ?? "", + detail: + typeof body?.detail === "string" ? body.detail : "The rename failed.", + plan: body?.plan, + } +} + +/** + * Renaming a flow's or a dashboard's key: a plan of what moves, read as the + * new key is typed, then a typed confirmation. + * + * A flow's rename pauses the engine until the work running at that moment is + * done, which can take as long as that work does. Until it starts changing + * things it can be called off, so the dialog shows what it waits for and a + * way out. Once it is done, `applyRename` moves this page along at once, its + * socket's copy of the news being the same rename. + */ +export function RenameDialog({ + kind, + id, + name, + open, + onOpenChange, + flush, +}: { + kind: Kind + id: string + /** The key as it is now. */ + name: string + open: boolean + onOpenChange: (open: boolean) => void + /** Sends what the editor still has queued, so the plan counts it. */ + flush?: () => Promise +}) { + const queryClient = useQueryClient() + const inputId = useId() + const confirmId = useId() + const [typed, setTyped] = useState("") + const [wanted, setWanted] = useState("") + const [confirm, setConfirm] = useState("") + const [op, setOp] = useState("") + const [waiting, setWaiting] = useState(null) + const [outcome, setOutcome] = useState<{ + detail: string + called_off?: boolean + } | null>(null) + + // Every opening starts over. + useEffect(() => { + if (!open) return + setTyped("") + setWanted("") + setConfirm("") + setOutcome(null) + }, [open]) + + useEffect(() => { + const timer = setTimeout(() => setWanted(typed.trim()), PLAN_DELAY) + return () => clearTimeout(timer) + }, [typed]) + + const planKey = [kind === "flow" ? "flows" : "dashboards", id, "rename"] + + const rename = useMutation({ + mutationFn: async (plan: RenamePlan) => { + await flush?.() + const requestBody: RenameRequest = { + request_id: requestKey(), + expected_name: plan.name, + expected_version: plan.version, + new_name: plan.new_name, + fingerprint: plan.fingerprint ?? "", + } + setOp(requestBody.request_id) + setWaiting(null) + setOutcome(null) + return kind === "flow" + ? FlowsService.renameFlow({ flowId: id, requestBody }) + : DashboardsService.renameDashboard({ dashboardId: id, requestBody }) + }, + onSuccess: (result) => { + onOpenChange(false) + notify(`Renamed ${result.old_name} to ${result.new_name}`, "success", { + id: `rename:${result.op}`, + }) + applyRename({ + type: "renamed", + op: result.op, + kind: result.kind, + id: result.id, + old: result.old_name, + new: result.new_name, + local: true, + }) + }, + onError: (error) => { + const { code, detail, plan } = errorOf(error) + if (code === "aborted") { + setOutcome({ detail: "Called off. Nothing changed.", called_off: true }) + return + } + setOutcome({ detail }) + // A refusal comes with the plan as it stands now, and a request the + // documents moved past asks for a fresh one. + if (plan) queryClient.setQueryData([...planKey, plan.new_name], plan) + else queryClient.invalidateQueries({ queryKey: planKey }) + }, + }) + const renaming = rename.isPending + + const plan = useQuery({ + queryKey: [...planKey, wanted], + queryFn: async () => { + await flush?.() + const requestBody = { new_name: wanted } + return kind === "flow" + ? FlowsService.planFlowRename({ flowId: id, requestBody }) + : DashboardsService.planDashboardRename({ + dashboardId: id, + requestBody, + }) + }, + enabled: open && wanted !== "" && !renaming, + staleTime: 0, + retry: false, + }) + + // What the drain waits for, as the engine says it each time it changes. + useEffect(() => { + if (!renaming) return + return listenRenames((event) => { + if (event.type === "rename_waiting" && event.op === op) + setWaiting(event.busy) + }) + }, [renaming, op]) + // And where it has got to, which decides whether it can still be called off. + const { data: summary } = useQuery({ + ...summaryQueryOptions(), + refetchInterval: 1000, + enabled: renaming && kind === "flow", + }) + const status = summary?.rename?.op === op ? summary.rename : null + const draining = + kind === "flow" && (status?.phase ?? "draining") === "draining" + const busy = waiting ?? status?.busy ?? [] + + const cancel = useMutation({ + mutationFn: () => + FlowsService.cancelFlowRename({ flowId: id, requestId: op }), + onError: (error) => { + if (errorOf(error).code === "too_late") + notify("Too late to call it off. The rename finishes.", "info") + }, + }) + + const current = plan.data?.new_name === typed.trim() ? plan.data : undefined + const ready = + Boolean(current?.allowed) && + confirm === name && + !renaming && + !cancel.isPending + + const noun = kind === "flow" ? "flow" : "dashboard" + const hold = (event: Event) => { + if (renaming) event.preventDefault() + } + + return ( + renaming || onOpenChange(next)}> + + + + Rename {name} + + + {kind === "flow" + ? "Its messages, and every flow and dashboard bound to them, move to the new key. The id and this page's address stay." + : "Its key in the API and in panel lists changes. The id, its links and the panels showing it stay."} + + + + {renaming ? ( + +

+ {draining + ? "Waiting for running work to finish. Automations are paused meanwhile." + : `Renaming to ${rename.variables?.new_name ?? ""}.`} +

+ {draining && busy.length ? ( +
+ Waiting for +
    + {busy.map((entry) => ( +
  • + {entry} +
  • + ))} +
+
+ ) : null} +
+ ) : ( +
+
+ + { + setTyped(event.target.value) + setOutcome(null) + }} + /> +
+ + {outcome ? ( + + + {outcome.detail} + + ) : null} + + {current ? ( + + ) : typed.trim() && plan.isError ? ( +

+ {errorOf(plan.error).detail} +

+ ) : typed.trim() ? ( +

Checking…

+ ) : null} + + {current?.allowed ? ( +
+ + setConfirm(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && ready && current) + rename.mutate(current) + }} + /> +
+ ) : null} +
+ )} + + + {renaming ? ( + draining ? ( + cancel.mutate()} + data-testid="rename-cancel" + > + Call off the rename + + ) : null + ) : ( + <> + + current && rename.mutate(current)} + data-testid="rename-key-submit" + > + Rename {noun} + + + )} + +
+
+ ) +} + +/** What a plan says moves, what stops it, and what it leaves behind. */ +function PlanSummary({ plan, noun }: { plan: RenamePlan; noun: string }) { + const refusals = plan.refusals ?? [] + const documents = plan.documents ?? [] + const warnings = plan.warnings ?? [] + const history = plan.history + const runtime = plan.runtime + const rows = history + ? history.metric_rows + history.artifact_rows + history.output_rows + : 0 + const queued = runtime + ? runtime.queue_items + runtime.delayed + runtime.parked + : 0 + + return ( +
+ {refusals.length ? ( + + + The {noun} cannot be renamed to this + +
    + {refusals.map((refusal) => ( +
  • {refusal.detail}
  • + ))} +
+
+
+ ) : null} + + {documents.length ? ( +
+ Rewritten +
    + {documents.map((document) => ( +
  • + + + {document.kind === "flow" ? "Flow" : "Dashboard"} + {" "} + {document.name} + + + {[ + document.id === plan.id && "renamed", + document.fields === 1 && "1 binding", + document.fields > 1 && `${document.fields} bindings`, + ] + .filter(Boolean) + .join(", ")} + +
  • + ))} +
+ {runtime ? ( +

+ Moves {si(rows)} history rows, {si(runtime.state_keys)} state keys + and {si(queued)} queued items + {plan.alerts?.channels + ? `, and ${plan.alerts.channels} alert channels` + : ""} + . +

+ ) : null} +
+ ) : null} + + {warnings.length ? ( +
+ Warnings +
    + {warnings.map((warning) => ( +
  • + + {warning.detail} +
  • + ))} +
+
+ ) : null} +
+ ) +} diff --git a/frontend/src/components/Flow/SidePanel.tsx b/frontend/src/components/Flow/SidePanel.tsx index 8e18fb1..e97ff78 100644 --- a/frontend/src/components/Flow/SidePanel.tsx +++ b/frontend/src/components/Flow/SidePanel.tsx @@ -1,4 +1,4 @@ -import { Check, Copy, Info, X } from "lucide-react" +import { Check, Copy, Info, Pencil, X } from "lucide-react" import { AnimatePresence, motion } from "motion/react" import type { ReactNode } from "react" import { useEffect, useState } from "react" @@ -173,18 +173,38 @@ export function IdentitySection({ name, id, help, + onRename, }: { name: string id: string help: ReactNode + /** Offers renaming the key, which only a superuser may. */ + onRename?: () => void }) { return (
Key
-
- {name} +
+ + {name} + + {onRename ? ( + + ) : null}
diff --git a/frontend/src/components/Flow/liveStore.check.ts b/frontend/src/components/Flow/liveStore.check.ts new file mode 100644 index 0000000..160ba2b --- /dev/null +++ b/frontend/src/components/Flow/liveStore.check.ts @@ -0,0 +1,48 @@ +/** + * The live store following a key rename, and a snapshot replacing what a + * page held, checked. + * + * cd frontend && bun run src/components/Flow/liveStore.check.ts + */ + +import assert from "node:assert/strict" + +import { liveStore } from "./liveStore" + +liveStore.setValue("heat.level", { value: 1, ts: 1 }) +liveStore.setValue("heating.level", { value: 2, ts: 2 }) +liveStore.setStatus("heat.sensor", { status: "error", error: "boom" }) +liveStore.setPaused("heat", true) +liveStore.appendLog({ + flow: "heat", + node: "heat.sensor", + text: "hello", + level: "info", + ts: 3, +}) + +let told = 0 +const unsubscribe = liveStore.subscribe("value:warm.level", () => { + told += 1 +}) +liveStore.renameFlow("heat", "warm") +unsubscribe() + +// Everything under `heat.` moves, and a key that only starts the same stays. +assert.equal(liveStore.getValue("heat.level"), undefined) +assert.deepEqual(liveStore.getValue("warm.level"), { value: 1, ts: 1 }) +assert.deepEqual(liveStore.getValue("heating.level"), { value: 2, ts: 2 }) +assert.equal(told, 1) +assert.equal(liveStore.getStatus("heat.sensor"), undefined) +assert.equal(liveStore.getStatus("warm.sensor")?.status, "error") +assert.equal(liveStore.getFailure("warm.sensor")?.error, "boom") + +// A snapshot names every value there is: what it leaves out is gone. +liveStore.setValues({ "warm.level": { value: 5, ts: 4 } }, true) +assert.equal(liveStore.getValue("heating.level"), undefined) +assert.equal(liveStore.getValue("warm.level")?.value, 5) +// Without `replace`, a run's values are only added. +liveStore.setValues({ "other.x": { value: 1, ts: 5 } }) +assert.equal(liveStore.getValue("warm.level")?.value, 5) + +console.log("liveStore: ok") diff --git a/frontend/src/components/Flow/liveStore.ts b/frontend/src/components/Flow/liveStore.ts index 160a104..28d7a4e 100644 --- a/frontend/src/components/Flow/liveStore.ts +++ b/frontend/src/components/Flow/liveStore.ts @@ -132,7 +132,19 @@ export const liveStore = { values.set(name, value) notify(`value:${name}`) }, - setValues(entries: Record) { + /** + * Values caught up on rather than seen arriving. With `replace`, as a + * snapshot sends them, whatever it leaves out is gone: a value under a key + * renamed while this page was not listening, among others. + */ + setValues(entries: Record, replace = false) { + if (replace) { + for (const name of [...values.keys()]) { + if (name in entries) continue + values.delete(name) + notify(`value:${name}`) + } + } for (const [name, value] of Object.entries(entries)) { values.set(name, { ...value, prior: true }) notify(`value:${name}`) @@ -267,6 +279,41 @@ export const liveStore = { notify(`paused:${flow}`) } }, + /** + * Follow a flow's key rename, as the engine did: whatever is named + * `from.x` (values, statuses, failures, emit counts) is `to.x` now, and + * the flow's pause and log lines go with it. + */ + renameFlow(from: string, to: string) { + const renamed = (name: string) => + name.startsWith(`${from}.`) ? `${to}${name.slice(from.length)}` : name + const move = (map: Map, kind: string) => { + for (const [key, entry] of [...map]) { + const next = renamed(key) + if (next === key) continue + map.delete(key) + map.set(next, entry) + notify(`${kind}:${key}`) + notify(`${kind}:${next}`) + } + } + move(values, "value") + move(statuses, "status") + move(failures, "failure") + move(emits, "emit") + move(priorEmits, "emit") + if (paused.delete(from)) { + paused.add(to) + notify(`paused:${from}`) + notify(`paused:${to}`) + } + logLines = logLines.map((line) => + line.flow === from + ? { ...line, flow: to, node: renamed(line.node) } + : line, + ) + notify("logs") + }, setConnected(next: boolean) { if (connected === next) return connected = next diff --git a/frontend/src/components/Flow/queries.ts b/frontend/src/components/Flow/queries.ts index 1d95895..3eb6df0 100644 --- a/frontend/src/components/Flow/queries.ts +++ b/frontend/src/components/Flow/queries.ts @@ -14,6 +14,7 @@ import { FlowsService, SecretsService, } from "@/client" +import { retryLater } from "@/utils" /** Keyed by flow id, which every flow route takes. */ export const flowKeys = { @@ -129,6 +130,8 @@ export function useParamSuggestions( const AUTOSAVE_DELAY = 800 /** How long to wait for a save in flight before sending the next one. */ const RETRY_DELAY = 100 +/** A save turned away by a rename goes again after this, its `Retry-After`. */ +export const RETRY_LATER = 5000 /** Publish the unpublished changes, which is what puts them on the engine. */ export function usePublish(id: string) { @@ -175,6 +178,10 @@ export function useAutosave(id: string): { resolveConflict: (mode: "theirs" | "mine") => Promise /** Take a version the server moved the draft to by itself. */ adopt: (next: number) => void + /** The version the last save got back, null before the first. */ + version: () => number | null + /** Whether an edit is queued, on its way or failed to arrive. */ + dirty: () => boolean mutation: UseMutationResult } { const queryClient = useQueryClient() @@ -185,9 +192,11 @@ export function useAutosave(id: string): { const version = useRef(null) const sent = useRef(null) const inFlight = useRef(false) + const failed = useRef(false) const [conflict, setConflict] = useState(false) const mutation = useMutation({ + meta: { autosave: true }, mutationFn: (definition: FlowDef_Input) => FlowsService.saveFlow({ flowId: id, @@ -208,6 +217,7 @@ export function useAutosave(id: string): { inFlight.current = false }, onSuccess: (detail) => { + failed.current = false version.current = detail.definition.version ?? null // Write the server's answer straight into the cache: invalidating would // pull the document back out from under edits still in flight. @@ -215,6 +225,7 @@ export function useAutosave(id: string): { queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true }) }, onError: (error) => { + failed.current = true if (error instanceof ApiError && error.status === 409) setConflict(true) }, }) @@ -240,7 +251,14 @@ export function useAutosave(id: string): { pending.current = null // A rejection here is a conflict, which the dialog handles; callers waiting // on the flush only need to know the attempt is over. - await mutateAsync(definition).catch(() => undefined) + await mutateAsync(definition).catch((error: unknown) => { + // Turned away while a rename runs: sent again once it may be done, + // unless a newer edit is already queued. One that the rename made stale + // is refused then, never written over it. + if (!retryLater(error, true) || pending.current) return + pending.current = definition + timer.current = setTimeout(() => void flush(), RETRY_LATER) + }) }, [mutateAsync]) const resolveConflict = useCallback( @@ -290,5 +308,20 @@ export function useAutosave(id: string): { version.current = next }, []) - return { save, flush, conflict, resolveConflict, adopt, mutation } + const current = useCallback(() => version.current, []) + const dirty = useCallback( + () => Boolean(pending.current) || inFlight.current || failed.current, + [], + ) + + return { + save, + flush, + conflict, + resolveConflict, + adopt, + version: current, + dirty, + mutation, + } } diff --git a/frontend/src/components/Flow/useFlowSocket.ts b/frontend/src/components/Flow/useFlowSocket.ts index 11618da..b8233d3 100644 --- a/frontend/src/components/Flow/useFlowSocket.ts +++ b/frontend/src/components/Flow/useFlowSocket.ts @@ -130,6 +130,7 @@ type FlowEvent = } /** `dashboard` is an id, or empty when only device assignments changed. */ | { type: "dashboard_changed"; dashboard?: string; ts?: number } + | RenameEvent | { type: "device_status" device: string @@ -156,6 +157,33 @@ type FlowEvent = } | LiveSignal +/** + * A key rename, as the socket tells it: what it waits for while it drains, + * then that it is done. `resync` is this page's own, for a socket that opened + * again and may have missed one. + */ +export type RenameEvent = + | { + type: "rename_waiting" + op: string + kind: "flow" | "dashboard" + id: string + busy: string[] + ts?: number + } + | { + type: "renamed" + op: string + kind: "flow" | "dashboard" + id: string + old: string + new: string + ts?: number + /** Applied from the answer of the dialog on this page that asked. */ + local?: boolean + } + | { type: "resync" } + /** * A live view's signalling, both ways on this socket (`fluksio.flow.live`). * The page sends `start`, `sdp`, `candidate` and `bye`, and hears `sdp` (the @@ -215,6 +243,10 @@ const authHandlers = new Set<() => void>() const healthTimers = new Map>() /** What each live session on this page does with what it hears. */ const liveListeners = new Map void>() +/** Editors and the rename dialog, told of a rename as it happens. */ +const renameListeners = new Set<(event: RenameEvent) => void>() +/** Renames already applied, so the socket's copy of one is not a second. */ +const applied = new Set() /** Send a live view's signalling. False while the socket is not open. */ export function sendLive(message: Omit): boolean { @@ -237,6 +269,44 @@ export function listenLive( } } +/** Hear of renames, until the returned function is called. */ +export function listenRenames( + listener: (event: RenameEvent) => void, +): () => void { + renameListeners.add(listener) + return () => { + renameListeners.delete(listener) + } +} + +/** Everything this socket feeds, which a reconnect or a rename may have moved. */ +function refetchAll() { + for (const queryKey of [ + flowKeys.all, + dashboardKeys.all, + dashboardKeys.messages, + deviceKeys.all, + runKeys.all, + healthKeys.all, + ]) { + client?.invalidateQueries({ queryKey }) + } +} + +/** + * A rename is done: the live state follows the key, every list and document + * is read again at the address it already had, and the editors decide + * whether what they hold is still current. The dialog that asked calls this + * with the answer, so a page with its socket down follows too. + */ +export function applyRename(event: Extract) { + if (applied.has(event.op)) return + applied.add(event.op) + if (event.kind === "flow") liveStore.renameFlow(event.old, event.new) + refetchAll() + for (const listener of [...renameListeners]) listener(event) +} + /** The socket went: so did every live session riding on it. */ function endLive() { for (const [session, listener] of [...liveListeners]) { @@ -336,22 +406,17 @@ function open(url: string) { // query the page holds, and the usual reason the socket dropped is the // engine restarting — so every open tab and every wall panel did that at // once, at the moment it was least able to answer. - for (const queryKey of [ - flowKeys.all, - dashboardKeys.all, - deviceKeys.all, - runKeys.all, - healthKeys.all, - ]) { - client?.invalidateQueries({ queryKey }) - } + refetchAll() + // A rename may have gone by meanwhile. The snapshot puts the live state + // right; an editor checks whether its document moved. + for (const listener of [...renameListeners]) listener({ type: "resync" }) sendWanted() } const handle = (message: FlowEvent) => { switch (message.type) { case "snapshot": - liveStore.setValues(message.values) + liveStore.setValues(message.values, true) liveStore.setStatuses(message.nodes ?? []) liveStore.setPausedFlows(message.paused ?? []) liveStore.setLogs(message.logs ?? []) @@ -497,6 +562,12 @@ function open(url: string) { }) else if (client) refetchDevices(client, false) break + case "rename_waiting": + for (const listener of [...renameListeners]) listener(message) + break + case "renamed": + applyRename(message) + break case "device_status": // A device's socket opened or closed: the Devices tab reads presence // off the list. diff --git a/frontend/src/components/Health/FlowTable.tsx b/frontend/src/components/Health/FlowTable.tsx index a65e5f9..b4831b8 100644 --- a/frontend/src/components/Health/FlowTable.tsx +++ b/frontend/src/components/Health/FlowTable.tsx @@ -12,6 +12,7 @@ import { Skeleton } from "@/components/ui/skeleton" import { Switch } from "@/components/ui/switch" import useCustomToast from "@/hooks/useCustomToast" import { dur, si } from "@/lib/utils" +import { retryLater } from "@/utils" import { CARD, flowRollupsQueryOptions } from "./queries" import { Spark } from "./Spark" @@ -139,7 +140,9 @@ function Row({ queryClient.invalidateQueries({ queryKey: flowKeys.all }) queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow.id!) }) }, - onError: () => showErrorToast("The flow could not be started or stopped."), + onError: (error) => + retryLater(error) || + showErrorToast("The flow could not be started or stopped."), }) // Nothing to report is not zero: a flow that never ran in this window and a diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d410ff3..cb55f10 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -21,6 +21,7 @@ import { apiToken, appPath, appRoute, portalConfig } from "./lib/portal" import { safeStorage } from "./lib/safeStorage" import { registerSW } from "./lib/webpush" import { routeTree } from "./routeTree.gen" +import { retryLater } from "./utils" const portal = portalConfig() @@ -92,7 +93,12 @@ const queryClient = new QueryClient({ onSuccess: () => connectionStore.setOnline(), }), mutationCache: new MutationCache({ - onError: handleApiError, + // A rename turns changes away for its duration: one card saying so, + // whichever mutation met it. An autosave says it sends itself again. + onError: (error, _variables, _context, mutation) => { + if (retryLater(error, mutation.meta?.autosave === true)) return + handleApiError(error) + }, }), defaultOptions: { queries: { diff --git a/frontend/src/routes/_canvas/dashboards/$id.tsx b/frontend/src/routes/_canvas/dashboards/$id.tsx index b9e4ac8..88d9fd7 100644 --- a/frontend/src/routes/_canvas/dashboards/$id.tsx +++ b/frontend/src/routes/_canvas/dashboards/$id.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" +import { useCallback, useState } from "react" import { DashboardEditor } from "@/components/Dashboard/DashboardEditor" import type { Dashboard } from "@/components/Dashboard/DashboardView" @@ -25,6 +26,10 @@ function DashboardRoute() { // 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(id, true)) + // Bumped when the editor has to take the server's document over its own, + // as after a rename: its draft is state seeded on mount. + const [epoch, setEpoch] = useState(0) + const reload = useCallback(() => setEpoch((n) => n + 1), []) if (!dashboard) return null @@ -34,9 +39,10 @@ function DashboardRoute() { {`${dashboard.title || dashboard.name} - Fluksio`} ) diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index d855b28..9d95064 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -1,6 +1,7 @@ import { AxiosError } from "axios" -import type { ApiError } from "./client" +import { ApiError } from "./client" import { offlineDetail } from "./lib/connectionStore" +import { notify } from "./lib/notificationStore" import { ago } from "./lib/utils" export function extractErrorMessage(err: ApiError): string { @@ -26,10 +27,33 @@ export function extractErrorMessage(err: ApiError): string { return (typeof errDetail === "string" && errDetail) || "Something went wrong." } +/** + * A change turned away while a key rename runs: a 503 with a `code`, which + * only the rename and the gate it closes answer. Said once however many + * requests meet it, and true so the caller says nothing more. + * + * @param saving whether it was an autosave, which sends itself again. + */ +export function retryLater(err: unknown, saving = false): boolean { + if (!(err instanceof ApiError) || err.status !== 503) return false + if (typeof (err.body as { code?: unknown } | null)?.code !== "string") { + return false + } + notify( + saving + ? "Paused while a rename finishes. Your changes are saved once it is done." + : "Paused while a rename finishes. Try again in a few seconds.", + "warning", + { id: "retry-later" }, + ) + return true +} + export const handleError = function ( this: (msg: string) => void, err: ApiError, ) { + if (retryLater(err)) return const errorMessage = extractErrorMessage(err) this(errorMessage) } diff --git a/frontend/tests/rename.spec.ts b/frontend/tests/rename.spec.ts new file mode 100644 index 0000000..619d76b --- /dev/null +++ b/frontend/tests/rename.spec.ts @@ -0,0 +1,380 @@ +import { + type Browser, + type BrowserContext, + expect, + type Page, + type Request, + type Route, + test, + type WebSocketRoute, +} from "@playwright/test" +import { api, apiPage, deleteAll } from "./utils/api" + +/** + * Renaming a key while the things bound to it are open. + * + * Every object here has an id the server issued, so nothing that addresses by + * id can pass by accident of the id being the key. The flow is renamed with a + * second editor holding a flow that reads it, and with a paired panel showing + * a dashboard bound to it, one of which misses the news while it happens. + */ + +const stamp = Date.now().toString(36) +const key = `test_rn_${stamp}` +const renamed = `${key}_new` +const readerKey = `${key}_reader` +const boardKey = `${key}_board` +const slowKey = `${key}_slow` +const deviceId = `${key}_panel` + +const ids = { flow: "", reader: "", board: "", slow: "" } +let deviceToken = "" + +test.use({ storageState: "playwright/.auth/user.json" }) +test.describe.configure({ mode: "serial" }) + +async function createFlow(page: Page, doc: Record) { + const made = await api(page, "/flows/", { method: "POST", data: doc }) + expect(made.status()).toBe(201) + return (await made.json()).definition.id as string +} + +async function publishFlow(page: Page, id: string) { + const detail = await (await api(page, `/flows/${id}`)).json() + await api(page, `/flows/${id}/publish`, { + method: "POST", + data: { version: detail.definition.version }, + }) +} + +async function flowDoc(page: Page, id: string) { + return (await (await api(page, `/flows/${id}`)).json()).definition +} + +/** A panel signed in with a device's own credential, as a wall screen is. */ +async function openPanel(browser: Browser): Promise<[BrowserContext, Page]> { + const context = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }) + await context.addInitScript((token) => { + // Also run on the blank page a context opens with, which has no storage. + if (location.protocol.startsWith("http")) + localStorage.setItem("access_token", token) + }, deviceToken) + return [context, await context.newPage()] +} + +/** Send a reading in through the flow's webhook, addressed by its id. */ +async function send(page: Page, level: number) { + await expect + .poll(async () => + ( + await page.request.post(`/hooks/${ids.flow}/ping`, { + data: { level }, + }) + ).status(), + ) + .toBe(200) +} + +/** Plan a rename in the open dialog and confirm it. */ +async function renameTo(page: Page, current: string, next: string) { + const dialog = page.getByTestId("rename-dialog") + await dialog.getByTestId("rename-key-input").fill(next) + await expect(dialog.getByTestId("rename-plan")).toBeVisible() + await dialog.getByTestId("rename-key-confirmation").fill(current) + await dialog.getByTestId("rename-key-submit").click() +} + +test.beforeAll(async ({ browser }) => { + const page = await apiPage(browser) + + ids.flow = await createFlow(page, { + name: key, + nodes: [ + { + id: "hook", + type: "http", + params: { url: "/ping", method: "POST" }, + requires: [], + provides: [{ name: "level", dtype: "float" }], + }, + ], + }) + await publishFlow(page, ids.flow) + + // Another flow reading it by its qualified name, which the rename rewrites. + ids.reader = await createFlow(page, { + name: readerKey, + nodes: [ + { + id: "double", + type: "python", + requires: [{ name: `${key}.level`, dtype: "float" }], + provides: [{ name: "twice", dtype: "float" }], + }, + ], + }) + await api(page, `/flows/${ids.reader}/nodes/double/source`, { + method: "PUT", + data: { code: 'def process(level):\n return {"twice": level * 2}\n' }, + }) + await publishFlow(page, ids.reader) + + // A dashboard bound to it, hanging on a panel of its own. + const board = await api(page, "/dashboards/", { + method: "POST", + data: { name: boardKey, title: "Rename board" }, + }) + const made = await board.json() + ids.board = made.id + const saved = await ( + await api(page, `/dashboards/${ids.board}`, { + method: "PUT", + data: { + ...made, + widgets: [ + { + id: "level", + type: "stat", + title: "Renamed level", + layout: { lg: { x: 0, y: 0, w: 3, h: 2 } }, + config: { message: `${key}.level`, dtype: "float" }, + }, + ], + }, + }) + ).json() + await api(page, `/dashboards/${ids.board}/publish`, { + method: "POST", + data: { version: saved.version }, + }) + const device = await ( + await api(page, `/devices/${deviceId}`, { method: "POST" }) + ).json() + await api(page, `/devices/${deviceId}`, { + method: "PUT", + data: { ...device, title: "Rename panel", dashboards: [ids.board] }, + }) + // Paired as a screen is: a code, approved, then collected by the device. + const pairing = await ( + await page.request.post("/api/v1/devices/pair", { data: {} }) + ).json() + await api(page, `/devices/${deviceId}/pair`, { + method: "POST", + data: { code: pairing.code }, + }) + deviceToken = ( + await ( + await page.request.get( + `/api/v1/devices/pair/${pairing.code}?secret=${pairing.secret}`, + ) + ).json() + ).access_token + expect(deviceToken).toBeTruthy() + + // A node that keeps a rename waiting while it runs. + ids.slow = await createFlow(page, { + name: slowKey, + nodes: [ + { + id: "slow", + type: "python", + provides: [{ name: "done", dtype: "float" }], + }, + ], + }) + await api(page, `/flows/${ids.slow}/nodes/slow/source`, { + method: "PUT", + data: { + code: 'import time\n\n\ndef process():\n time.sleep(12)\n return {"done": 1.0}\n', + }, + }) + await publishFlow(page, ids.slow) + await page.close() +}) + +test.afterAll(async ({ browser }) => { + await deleteAll( + browser, + [ + `/devices/${deviceId}`, + ids.board && `/dashboards/${ids.board}`, + ids.reader && `/flows/${ids.reader}`, + ids.flow && `/flows/${ids.flow}`, + ids.slow && `/flows/${ids.slow}`, + ].filter(Boolean), + ) +}) + +test("a flow rename moves editors, panels and values at the same addresses", async ({ + page, + browser, +}) => { + test.setTimeout(120_000) + // The panels first, one hearing everything and one that will miss it. + const [heard, panel] = await openPanel(browser) + const [deafContext, deaf] = await openPanel(browser) + const sockets: WebSocketRoute[] = [] + let muted = false + await deaf.routeWebSocket(/\/api\/v1\/flows\/ws/, (ws) => { + const server = ws.connectToServer() + sockets.push(ws) + server.onMessage((message) => { + if (!muted) ws.send(message) + }) + }) + for (const screen of [panel, deaf]) { + await screen.goto(`/panel/${deviceId}`) + await expect(screen.getByText("Renamed level")).toBeVisible() + } + await send(page, 7) + for (const screen of [panel, deaf]) { + await expect(screen.getByTestId("canvas-surface")).toContainText("7") + } + + // A second editor on the flow that reads it, with an edit on its way that + // the rename will overtake. + const reader = await page.context().newPage() + await reader.goto(`/flows/${ids.reader}`) + await reader.waitForSelector(".react-flow__node") + let held: Route | null = null + const heldRequest = new Promise((resolve) => { + reader.route(`**/api/v1/flows/${ids.reader}`, (route) => { + if (route.request().method() !== "PUT") return route.continue() + held = route + resolve(route.request()) + }) + }) + await reader.getByTestId("edit-flow").click() + await reader.getByLabel("Flow title").fill("Edited before") + await reader.getByTestId("confirm-rename").click() + const stale = await heldRequest + + // The rename, from the flow's own editor. + await page.goto(`/flows/${ids.flow}`) + await page.waitForSelector(".react-flow__node") + await page.getByTestId("edit-flow").click() + await page.getByTestId("flow-panel").getByTestId("rename-key").click() + muted = true + const dialog = page.getByTestId("rename-dialog") + await dialog.getByTestId("rename-key-input").fill(renamed) + const documents = dialog.getByTestId("rename-documents") + await expect(documents).toContainText(key) + await expect(documents).toContainText(readerKey) + await expect(documents).toContainText(boardKey) + await expect(dialog.getByTestId("rename-warnings")).toContainText( + `/messages/${key}.*`, + ) + await dialog.getByTestId("rename-key-confirmation").fill(key) + await dialog.getByTestId("rename-key-submit").click() + await expect(dialog).toBeHidden({ timeout: 30_000 }) + + // Same address, new key. + expect(new URL(page.url()).pathname).toBe(`/flows/${ids.flow}`) + await page.getByTestId("edit-flow").click() + await expect( + page.getByTestId("flow-panel").getByTestId("identity-key"), + ).toHaveText(renamed) + const reading = (await flowDoc(page, ids.reader)).nodes[0].requires[0].name + expect(reading).toBe(`${renamed}.level`) + + // The other editor took the renamed document at its own address, and the + // edit it was still sending is refused rather than written over it. + expect(new URL(reader.url()).pathname).toBe(`/flows/${ids.reader}`) + const refused = reader.waitForResponse( + (response) => response.request() === stale, + ) + await (held as Route | null)?.continue() + expect((await refused).status()).toBe(409) + await reader.unroute(`**/api/v1/flows/${ids.reader}`) + await expect(reader.getByTestId("save-conflict")).toBeHidden() + // Nor does undoing bring the old binding back: that history went too. + await reader.locator(".react-flow__pane").click() + await reader.keyboard.press("ControlOrMeta+z") + await reader.waitForTimeout(1500) + const after = await flowDoc(page, ids.reader) + expect(after.nodes[0].requires[0].name).toBe(`${renamed}.level`) + expect(after.title ?? "").not.toBe("Edited before") + + // A reading under the new key reaches the panel that heard the rename. + await send(page, 9) + await expect(panel.getByTestId("canvas-surface")).toContainText("9") + // The one that missed it catches up once its socket is back. + await expect(deaf.getByTestId("canvas-surface")).not.toContainText("9") + muted = false + for (const ws of sockets) await ws.close() + await expect(deaf.getByTestId("canvas-surface")).toContainText("9", { + timeout: 20_000, + }) + expect(new URL(deaf.url()).pathname).toBe(`/panel/${deviceId}`) + + await heard.close() + await deafContext.close() + await reader.close() +}) + +test("a dashboard rename keeps both editors at its address", async ({ + page, +}) => { + const other = await page.context().newPage() + await other.goto(`/dashboards/${ids.board}?edit=true`) + await expect(other.getByText("Renamed level")).toBeVisible() + + await page.goto(`/dashboards/${ids.board}?edit=true`) + await page.getByTestId("edit-dashboard").click() + await page.getByTestId("dashboard-panel").getByTestId("rename-key").click() + await renameTo(page, boardKey, `${boardKey}_new`) + await expect(page.getByTestId("rename-dialog")).toBeHidden() + expect(new URL(page.url()).pathname).toBe(`/dashboards/${ids.board}`) + await page.getByTestId("edit-dashboard").click() + await expect( + page.getByTestId("dashboard-panel").getByTestId("identity-key"), + ).toHaveText(`${boardKey}_new`) + + // The other editor reloaded on the renamed draft, so its next save is + // based on it rather than refused or written over it. + await other.getByTestId("edit-dashboard").click() + const panel = other.getByTestId("dashboard-panel") + await expect(panel.getByTestId("identity-key")).toHaveText(`${boardKey}_new`) + await panel.getByLabel("Dashboard title").fill("Saved after") + await panel.getByTestId("confirm-rename").click() + await expect + .poll( + async () => + (await (await api(page, `/dashboards/${ids.board}?draft=true`)).json()) + .title, + ) + .toBe("Saved after") + const board = await ( + await api(page, `/dashboards/${ids.board}?draft=true`) + ).json() + expect(board.name).toBe(`${boardKey}_new`) + await other.close() +}) + +test("a rename waiting on running work can be called off", async ({ page }) => { + test.setTimeout(60_000) + await page.goto(`/flows/${ids.slow}`) + await page.waitForSelector(".react-flow__node") + // Running, and so in the way, before the rename is asked for. + const running = api(page, `/flows/${ids.slow}/nodes/slow/trigger`, { + method: "POST", + data: { values: {} }, + timeout: 60_000, + }) + await page.getByTestId("edit-flow").click() + await page.getByTestId("flow-panel").getByTestId("rename-key").click() + await renameTo(page, slowKey, `${slowKey}_new`) + + const dialog = page.getByTestId("rename-dialog") + await expect(dialog.getByTestId("rename-busy")).toContainText( + `/flows/${ids.slow}/nodes/slow/trigger`, + ) + await dialog.getByTestId("rename-cancel").click() + await expect(dialog.getByTestId("rename-outcome")).toContainText( + "Nothing changed", + ) + expect((await flowDoc(page, ids.slow)).name).toBe(slowKey) + expect((await running).status()).toBe(200) +})