From fa15522cb0915c4b818f9f98b0502984598b56ca Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 2 Nov 2026 19:57:43 +0100 Subject: [PATCH] Frontend addresses flows and dashboards by id: routes /flows/$flowId, /dashboards/$id and /view/$id, query keys, saves, lists, device pickers, search, brain and endpoint links and generated results dashboards go by id, while live values, issues, pauses and message names keep the key; tabs take the title from the document; creating uses POST and opens the issued id; the flow and dashboard panels show key and id with a copy button and a served HTTP or WebSocket node shows its hook URL under the flow id; specs create by POST and ids.spec.ts exercises issued ids --- docs/interface/dashboards.md | 8 +- docs/interface/flow-editor.md | 12 + frontend/scripts/capture-screenshots.mjs | 21 +- frontend/src/components/Chat/ChatPanel.tsx | 10 +- .../src/components/Common/DashboardMosaic.tsx | 6 +- .../src/components/Common/GlobalSearch.tsx | 29 +-- .../src/components/Common/OverviewToolbar.tsx | 30 ++- .../components/Dashboard/DashboardEditor.tsx | 29 +-- .../components/Dashboard/DashboardPalette.tsx | 6 +- .../components/Dashboard/DashboardView.tsx | 2 +- .../src/components/Dashboard/PanelRail.tsx | 7 +- .../src/components/Dashboard/PanelSurface.tsx | 2 +- .../src/components/Dashboard/RoundFace.tsx | 2 +- .../src/components/Dashboard/RunsContext.tsx | 6 +- frontend/src/components/Dashboard/panels.tsx | 13 +- frontend/src/components/Dashboard/queries.ts | 37 +-- frontend/src/components/Dashboard/widgets.tsx | 1 + .../src/components/Devices/DeviceDetail.tsx | 51 ++-- frontend/src/components/Flow/BrainNode.tsx | 2 + frontend/src/components/Flow/BrainView.tsx | 15 +- .../src/components/Flow/CommandPalette.tsx | 6 +- .../src/components/Flow/EdgeInspector.tsx | 6 +- frontend/src/components/Flow/FlowEditor.tsx | 87 ++++--- frontend/src/components/Flow/FlowNode.tsx | 8 +- frontend/src/components/Flow/FlowPanel.tsx | 15 +- .../src/components/Flow/InputInspector.tsx | 4 + .../src/components/Flow/MessageSparkline.tsx | 5 +- frontend/src/components/Flow/NodePanel.tsx | 94 +++++-- frontend/src/components/Flow/SidePanel.tsx | 78 +++++- frontend/src/components/Flow/liveStore.ts | 11 +- frontend/src/components/Flow/queries.ts | 55 ++-- frontend/src/components/Flow/useFlowSocket.ts | 32 ++- frontend/src/components/Health/FlowTable.tsx | 15 +- .../src/components/Health/HealthActivity.tsx | 6 +- frontend/src/components/Runs/NewRun.tsx | 7 +- .../src/components/Runs/OpenInDashboard.tsx | 41 +-- frontend/src/components/Runs/RunDetail.tsx | 8 +- frontend/src/components/Runs/RunsScreen.tsx | 24 +- frontend/src/routeTree.gen.ts | 102 ++++---- .../_canvas/dashboards/{$name.tsx => $id.tsx} | 23 +- .../flows/{$flowName.tsx => $flowId.tsx} | 11 +- .../src/routes/_layout/dashboards/index.tsx | 31 ++- frontend/src/routes/_layout/flows/index.tsx | 36 ++- frontend/src/routes/panel.$id.tsx | 18 +- frontend/src/routes/panel.index.tsx | 2 +- .../routes/{view.$name.tsx => view.$id.tsx} | 13 +- frontend/tests/clipboard.spec.ts | 8 +- frontend/tests/color-drag.spec.ts | 102 ++++---- frontend/tests/drafts.spec.ts | 24 +- frontend/tests/editor.spec.ts | 4 +- frontend/tests/endpoints.spec.ts | 55 ++-- frontend/tests/flows.spec.ts | 91 ++++--- frontend/tests/ids.spec.ts | 235 ++++++++++++++++++ frontend/tests/live.spec.ts | 35 ++- frontend/tests/mobile.spec.ts | 102 ++++---- frontend/tests/panel.spec.ts | 41 +-- frontend/tests/persistence.spec.ts | 28 +-- frontend/tests/ring.spec.ts | 78 +++--- frontend/tests/runs.spec.ts | 45 ++-- frontend/tests/runtime.spec.ts | 35 ++- frontend/tests/search.spec.ts | 29 +-- frontend/tests/slider-drag.spec.ts | 82 +++--- frontend/tests/utils/api.ts | 47 ++++ frontend/tests/values.spec.ts | 33 ++- frontend/tests/widgets.spec.ts | 63 ++--- 65 files changed, 1380 insertions(+), 784 deletions(-) rename frontend/src/routes/_canvas/dashboards/{$name.tsx => $id.tsx} (65%) rename frontend/src/routes/_canvas/flows/{$flowName.tsx => $flowId.tsx} (76%) rename frontend/src/routes/{view.$name.tsx => view.$id.tsx} (91%) create mode 100644 frontend/tests/ids.spec.ts diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 5034a41..1af4931 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -256,6 +256,12 @@ when no usable message value is available. To change one on a schedule, have a scheduled node publish its bound message. +The top of the panel shows the dashboard's key and its id, with a copy button +for the id. The id never changes: the editor, `/view/{id}` and the devices +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. + ### Look and theme **Fluksio** is the default look. **Material** uses flat tonal cards; **Glass** @@ -448,7 +454,7 @@ under the finger, as a tile view moves. ## Showing one -Open `/view/{name}` for a dashboard-only view with your normal session. +Open `/view/{id}` for a dashboard-only view with your normal session. For a dedicated screen, pair a device. ## Devices: hanging a screen on a wall diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index 67e794e..cb89cff 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -82,6 +82,12 @@ limit, the default. memory and GPU requirements. Placement by `worker` and `worker_policy` is configured through the API or flow JSON; see [Remote workers](../code/workers.md). +An HTTP or WebSocket node whose URL is a path, such as `/tick`, is served by +the engine. The panel then shows the full **Webhook URL** (or **Socket URL**) +with a copy button. It sits under the flow's id, `/hooks//tick`, so it +keeps working whatever the flow is called. A stored secret is not shown. The +URL names it as the last segment for you to fill in. + ### Code and reuse Define `process(...)` in **Code**. If the file instead contains exactly one @@ -105,12 +111,18 @@ Open **Flow settings** from the pencil button in the dock. | Setting | Purpose | |---|---| +| **Identity** | shows the flow's key, the namespace of its messages, and its id with a copy button | | **Running** | starts or stops the published live flow's subscriptions, schedules and webhooks | | **Mode** | chooses continuous `live` operation or requested `batch` runs | | **Inputs** | declares external messages, their starting values and whether a run or a panel writes them; run inputs become run parameters | | **Result** | selects batch output messages | | **Contents** | shows the node count and message namespace | +The id never changes. The editor's address, hook URLs and the API use it. A +flow that existed before ids were introduced kept its key as its id, while a +flow created now gets an opaque one such as `f_k3q7m2xa`. The tab and the +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. diff --git a/frontend/scripts/capture-screenshots.mjs b/frontend/scripts/capture-screenshots.mjs index 7283743..952446b 100644 --- a/frontend/scripts/capture-screenshots.mjs +++ b/frontend/scripts/capture-screenshots.mjs @@ -133,9 +133,19 @@ async function captureRuns(page, dir) { }).catch(() => null) if (!answer?.ok) return null const body = await answer.json() - return (body.data || []) - .map((one) => one.name) - .find((n) => n.endsWith("_results")) + const board = (body.data || []).find((one) => one.name.endsWith("_results")) + if (!board) return null + // Named after its flow's key; the runs screen filters by the flow's id. + const flows = await fetch( + `/api/v1/flows/?name=${board.name.replace(/_results$/, "")}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("access_token")}`, + }, + }, + ).then((reply) => (reply.ok ? reply.json() : { data: [] })) + const flow = flows.data?.[0]?.id + return flow ? { id: board.id, flow } : null }) // A dashboard read against two runs: the same page a live run is watched @@ -144,8 +154,7 @@ async function captureRuns(page, dir) { // the table is filtered to its flow rather than read from the top. Another // flow's run finishing later would otherwise photograph an empty grid. if (results) { - const flow = results.replace(/_results$/, "") - await page.goto(`${APP_URL}/runs?flow=${flow}`, { + await page.goto(`${APP_URL}/runs?flow=${results.flow}`, { waitUntil: "networkidle", }) const ids = await page.getByTestId("run-link").evaluateAll((links) => @@ -155,7 +164,7 @@ async function captureRuns(page, dir) { .filter(Boolean), ) if (ids.length) { - await page.goto(`${APP_URL}/view/${results}?runs=${ids.join(",")}`, { + await page.goto(`${APP_URL}/view/${results.id}?runs=${ids.join(",")}`, { waitUntil: "networkidle", }) await page.waitForTimeout(2000) diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index fcfe8ed..55ed93c 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -4,7 +4,7 @@ import { Bird, Bot, MessageCircle, Send, Square, Trash2 } from "lucide-react" import { lazy, Suspense, useEffect, useRef, useState } from "react" import { AiService, type ChatItem } from "@/client" -import { flowKeys } from "@/components/Flow/queries" +import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries" import { SidePanel } from "@/components/Flow/SidePanel" import { Button } from "@/components/ui/button" import { FluksioLoader } from "@/components/ui/fluksio-loader" @@ -182,7 +182,13 @@ export function ChatPanel() { const node = nodes.find((n) => n.id === nodeId) ?? nodes[0] const control = useControl(node) const pathname = useRouterState({ select: (s) => s.location.pathname }) - const flow = FLOW_PATH.exec(pathname)?.[1] + // The address holds the flow's id; the assistant is told its key. + const flowId = FLOW_PATH.exec(pathname)?.[1] + const { data: flowList } = useQuery({ + ...flowsQueryOptions(), + enabled: Boolean(flowId), + }) + const flow = flowList?.data.find((one) => one.id === flowId)?.name const [items, setItems] = useState([]) const [loaded, setLoaded] = useState(false) diff --git a/frontend/src/components/Common/DashboardMosaic.tsx b/frontend/src/components/Common/DashboardMosaic.tsx index b536b1e..b815af0 100644 --- a/frontend/src/components/Common/DashboardMosaic.tsx +++ b/frontend/src/components/Common/DashboardMosaic.tsx @@ -156,8 +156,8 @@ function Tile({ }) { return ( {dashboards.map((dashboard) => ( - + ))} ) diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx index e47eb6b..6055a85 100644 --- a/frontend/src/components/Common/GlobalSearch.tsx +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -159,46 +159,39 @@ export function GlobalSearch({ else into.set(entry.category, [entry]) } + // `id` is the flow or dashboard to open, the entry's own or its parent's; + // `parent` is only the key, for reading. const go = (entry: SearchEntry) => { onOpenChange(false) setQuery("") + const id = entry.id ?? "" switch (entry.category) { case "flow": - return navigate({ - to: "/flows/$flowName", - params: { flowName: entry.name }, - }) + return navigate({ to: "/flows/$flowId", params: { flowId: id } }) case "node": return navigate({ - to: "/flows/$flowName", - params: { flowName: entry.parent ?? "" }, + to: "/flows/$flowId", + params: { flowId: id }, search: { node: entry.name }, }) // A setting is `.`, and a node id has no dot. case "setting": return navigate({ - to: "/flows/$flowName", - params: { flowName: entry.parent ?? "" }, + to: "/flows/$flowId", + params: { flowId: id }, search: { node: entry.name.split(".")[0] }, }) // Arriving on the label's id does what clicking the label does. case "input": case "output": return navigate({ - to: "/flows/$flowName", - params: { flowName: entry.parent ?? "" }, + to: "/flows/$flowId", + params: { flowId: id }, search: { node: `${entry.category}:${entry.name}` }, }) case "dashboard": - return navigate({ - to: "/dashboards/$name", - params: { name: entry.name }, - }) case "widget": - return navigate({ - to: "/dashboards/$name", - params: { name: entry.parent ?? "" }, - }) + return navigate({ to: "/dashboards/$id", params: { id } }) case "device": return navigate({ to: "/devices/$id", params: { id: entry.name } }) case "secret": diff --git a/frontend/src/components/Common/OverviewToolbar.tsx b/frontend/src/components/Common/OverviewToolbar.tsx index 11be985..391ece4 100644 --- a/frontend/src/components/Common/OverviewToolbar.tsx +++ b/frontend/src/components/Common/OverviewToolbar.tsx @@ -220,6 +220,8 @@ export function OverviewToolbar({ * * @param done what the toast says happened to them, "Published" * @param missed what the ones that failed still are, "Still unpublished" + * @param labelOf what a failed one is called in the toast, when what is + * acted on is an id nobody would recognise */ function useBatch( act: (name: string) => Promise, @@ -227,6 +229,7 @@ function useBatch( done: string, missed: string, onDone: () => void, + labelOf: (name: string) => string = (name) => name, ) { const { showSuccessToast, showErrorToast } = useCustomToast() return useMutation({ @@ -236,7 +239,9 @@ function useBatch( try { await act(name) } catch (err) { - failed.push(`${name} (${extractErrorMessage(err as ApiError)})`) + failed.push( + `${labelOf(name)} (${extractErrorMessage(err as ApiError)})`, + ) } } return failed @@ -261,8 +266,16 @@ export function usePublishAll( publish: (name: string) => Promise, noun: string, onDone: () => void, + labelOf?: (name: string) => string, ) { - return useBatch(publish, noun, "Published", "Still unpublished", onDone) + return useBatch( + publish, + noun, + "Published", + "Still unpublished", + onDone, + labelOf, + ) } /** @@ -273,8 +286,9 @@ export function useDeleteSelected( remove: (name: string) => Promise, noun: string, onDone: () => void, + labelOf?: (name: string) => string, ) { - return useBatch(remove, noun, "Deleted", "Still there", onDone) + return useBatch(remove, noun, "Deleted", "Still there", onDone, labelOf) } /** @@ -285,8 +299,16 @@ export function useDuplicateSelected( duplicate: (name: string) => Promise, noun: string, onDone: () => void, + labelOf?: (name: string) => string, ) { - return useBatch(duplicate, noun, "Duplicated", "Not duplicated", onDone) + return useBatch( + duplicate, + noun, + "Duplicated", + "Not duplicated", + onDone, + labelOf, + ) } /** diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index b72c5f9..e77f8a0 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -385,6 +385,8 @@ export function DashboardEditor({ edit: boolean }) { const [draft, setDraft] = useState(dashboard) + // What the API, the routes and the devices know it by; never edited. + const id = dashboard.id! const [selected, setSelected] = useState(null) // The face a round dashboard shows: the widget picked, or the one last // picked or paged to once its settings are put away. @@ -403,9 +405,9 @@ export function DashboardEditor({ const root = useCanvasRoot(draft) const navigate = useNavigate() const queryClient = useQueryClient() - const save = useSaveDashboard(dashboard.name) - const publish = usePublishDashboard(dashboard.name) - const discard = useDiscardDashboardDraft(dashboard.name) + const save = useSaveDashboard(id) + const publish = usePublishDashboard(id) + const discard = useDiscardDashboardDraft(id) const [discardOpen, setDiscardOpen] = useState(false) const { showSuccessToast, showErrorToast } = useCustomToast() const copied = useCopiedWidget() @@ -484,8 +486,7 @@ export function DashboardEditor({ } const remove = useMutation({ - mutationFn: () => - DashboardsService.deleteDashboard({ dashboardId: draft.name }), + mutationFn: () => DashboardsService.deleteDashboard({ dashboardId: id }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: dashboardKeys.all }) navigate({ to: "/dashboards" }) @@ -590,7 +591,7 @@ export function DashboardEditor({ (devices?.data ?? []).find( (device) => (device.dashboards ?? []).length > 1 && - (device.dashboards ?? []).includes(draft.name), + (device.dashboards ?? []).includes(id), )?.dashboards ?? null // A round face has no room for one: a panel swipes between those instead. const hasRail = Boolean(railDashboards) && !stacked && !round @@ -598,13 +599,13 @@ export function DashboardEditor({ railDashboards && !round ? ( ({ - to: "/dashboards/$name", - params: { name }, + linkFor={(other) => ({ + to: "/dashboards/$id", + params: { id: other }, search: edit ? { edit: true } : {}, })} /> @@ -675,8 +676,8 @@ export function DashboardEditor({ setSelected(null) setSettingsOpen(false) navigate({ - to: "/dashboards/$name", - params: { name: draft.name }, + to: "/dashboards/$id", + params: { id }, search: next ? { edit: true } : {}, }) } @@ -685,7 +686,7 @@ export function DashboardEditor({ const pickable = (widget: WidgetDef, grip = false) => ( { @@ -985,7 +986,7 @@ export function DashboardEditor({ asChild > diff --git a/frontend/src/components/Dashboard/DashboardPalette.tsx b/frontend/src/components/Dashboard/DashboardPalette.tsx index 4008449..cbe70bd 100644 --- a/frontend/src/components/Dashboard/DashboardPalette.tsx +++ b/frontend/src/components/Dashboard/DashboardPalette.tsx @@ -97,14 +97,14 @@ export function DashboardPalette({ {dashboards.map((dashboard) => ( close(() => // Editing a neighbour is the same job, as on the rail. navigate({ - to: "/dashboards/$name", - params: { name: dashboard.name }, + to: "/dashboards/$id", + params: { id: dashboard.id! }, search: { edit: true }, }), ) diff --git a/frontend/src/components/Dashboard/DashboardView.tsx b/frontend/src/components/Dashboard/DashboardView.tsx index fc91f27..e1c8a40 100644 --- a/frontend/src/components/Dashboard/DashboardView.tsx +++ b/frontend/src/components/Dashboard/DashboardView.tsx @@ -536,7 +536,7 @@ function WidgetGrid({ issue={widgetIssue(widget)} scroll={scrolls(widget)} > - + )} diff --git a/frontend/src/components/Dashboard/PanelRail.tsx b/frontend/src/components/Dashboard/PanelRail.tsx index a48126b..4cbe221 100644 --- a/frontend/src/components/Dashboard/PanelRail.tsx +++ b/frontend/src/components/Dashboard/PanelRail.tsx @@ -28,20 +28,21 @@ export function PanelRail({ linkFor, horizontal, }: { + /** Dashboard ids, in the order the device carries them. */ dashboards: string[] current: string - linkFor: (name: string) => LinkProps + linkFor: (id: string) => LinkProps horizontal?: boolean }) { const { Rail } = useUi() const box = useRef(null) const still = useReducedMotion() const entries = useQueries({ - queries: dashboards.map((name) => dashboardQueryOptions(name)), + queries: dashboards.map((id) => dashboardQueryOptions(id)), combine: (results) => results.map((result, index) => ({ name: dashboards[index], - label: result.data?.title || dashboards[index], + label: result.data?.title || result.data?.name || dashboards[index], icon: result.data?.icon ?? "", active: dashboards[index] === current, link: linkFor(dashboards[index]), diff --git a/frontend/src/components/Dashboard/PanelSurface.tsx b/frontend/src/components/Dashboard/PanelSurface.tsx index d30b2cc..b5240a4 100644 --- a/frontend/src/components/Dashboard/PanelSurface.tsx +++ b/frontend/src/components/Dashboard/PanelSurface.tsx @@ -16,7 +16,7 @@ import { cn } from "@/lib/utils" * One dashboard filling whatever screen it landed on. * * The whole of what a wall panel draws, shared by the single-dashboard route - * (`/view/{name}`) and the paired-panel one (`/panel/{id}`) so a device shows + * (`/view/{id}`) and the paired-panel one (`/panel/{id}`) so a device shows * the same thing either way — the second merely has a rail beside it. * * The dashboard's theme is stated by the route rather than here: on these two diff --git a/frontend/src/components/Dashboard/RoundFace.tsx b/frontend/src/components/Dashboard/RoundFace.tsx index 5241459..92ec138 100644 --- a/frontend/src/components/Dashboard/RoundFace.tsx +++ b/frontend/src/components/Dashboard/RoundFace.tsx @@ -601,7 +601,7 @@ export function RoundView({ > onPick(one.id))} diff --git a/frontend/src/components/Dashboard/RunsContext.tsx b/frontend/src/components/Dashboard/RunsContext.tsx index b9ebbfc..615eb0d 100644 --- a/frontend/src/components/Dashboard/RunsContext.tsx +++ b/frontend/src/components/Dashboard/RunsContext.tsx @@ -80,8 +80,10 @@ export function RunsContextProvider({ const finals = new Map() if (primary) { const at = primary.finished_at ? Date.parse(String(primary.finished_at)) : 0 + // Results are named under the flow's key, which `flow` (its id) is not. + const namespace = primary.flow_name || primary.flow for (const [key, value] of Object.entries(primary.result ?? {})) { - finals.set(key.includes(".") ? key : `${primary.flow}.${key}`, { + finals.set(key.includes(".") ? key : `${namespace}.${key}`, { value, ts: at ? at / 1000 : null, }) @@ -105,7 +107,7 @@ export function RunsContextProvider({ ) }) - const flow = primary?.flow ?? "" + const flow = primary?.flow_name || primary?.flow || "" const context: DataContext = { kind: "runs", label: diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index bbc07f3..909142e 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -8,6 +8,7 @@ import { screensOf } from "@/components/Devices/ports" import { devicesQueryOptions } from "@/components/Devices/queries" import { FieldLabel, + IdentitySection, InfoTip, PANEL_SECTION, PanelSection, @@ -241,6 +242,8 @@ function RunsSourceFields({ }) { const { data: flows } = useQuery(runOverviewQueryOptions()) const mode = runs.ids?.length ? "ids" : runs.group ? "group" : "latest" + // The pick holds the flow's id; its metrics are named under its key. + const key = flows?.find((row) => row.flow === runs.flow)?.flow_name return (
@@ -256,7 +259,7 @@ function RunsSourceFields({ {(flows ?? []).map((row) => ( - {row.flow} + {row.flow_name || row.flow} ))} @@ -271,7 +274,7 @@ function RunsSourceFields({ placeholder="Pick a metric" filter={(message) => (message.dtype === "float" || message.dtype === "int") && - (!runs.flow || message.name.startsWith(`${runs.flow}.`)) + (!runs.flow || !key || message.name.startsWith(`${key}.`)) } onPick={(metric) => onChange({ ...runs, metric })} /> @@ -1980,6 +1983,12 @@ export function DashboardPanel({ } >
+ + - ["dashboards", name, draft ? "draft" : "published"] as const, + /** By id. The working copy and the published document are two reads. */ + detail: (id: string, draft = false) => + ["dashboards", id, draft ? "draft" : "published"] as const, messages: ["messages"] as const, history: (message: string) => ["messages", message, "history"] as const, } @@ -26,9 +26,9 @@ export const dashboardsQueryOptions = () => ({ * 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({ dashboardId: name, draft }), +export const dashboardQueryOptions = (id: string, draft = false) => ({ + queryKey: dashboardKeys.detail(id, draft), + queryFn: () => DashboardsService.readDashboard({ dashboardId: id, draft }), }) /** Every message any flow declares — what a widget can be pointed at. */ @@ -46,44 +46,44 @@ export const messageHistoryQueryOptions = (message: string) => ({ * * This writes the draft; panels keep showing the published document. */ -export function useSaveDashboard(name: string) { +export function useSaveDashboard(id: string) { const queryClient = useQueryClient() return useMutation({ mutationFn: (body: DashboardDef_Input) => - DashboardsService.saveDashboard({ dashboardId: name, requestBody: body }), + DashboardsService.saveDashboard({ dashboardId: id, requestBody: body }), onSuccess: (saved) => { - queryClient.setQueryData(dashboardKeys.detail(name, true), saved) + queryClient.setQueryData(dashboardKeys.detail(id, true), saved) queryClient.invalidateQueries({ queryKey: dashboardKeys.all }) }, }) } /** Publish the unpublished changes, which is what puts them on the panels. */ -export function usePublishDashboard(name: string) { +export function usePublishDashboard(id: string) { const queryClient = useQueryClient() return useMutation({ mutationFn: (version: number) => DashboardsService.publishDashboard({ - dashboardId: name, + dashboardId: id, requestBody: { version }, }), onSuccess: (published) => { - queryClient.setQueryData(dashboardKeys.detail(name, true), published) - queryClient.setQueryData(dashboardKeys.detail(name), published) + queryClient.setQueryData(dashboardKeys.detail(id, true), published) + queryClient.setQueryData(dashboardKeys.detail(id), published) queryClient.invalidateQueries({ queryKey: dashboardKeys.all }) }, }) } /** Throw the unpublished edit away; the panels keep showing what they had. */ -export function useDiscardDashboardDraft(name: string) { +export function useDiscardDashboardDraft(id: string) { const queryClient = useQueryClient() return useMutation({ mutationFn: () => - DashboardsService.discardDashboardDraft({ dashboardId: name }), + DashboardsService.discardDashboardDraft({ dashboardId: id }), onSuccess: (published) => { - queryClient.setQueryData(dashboardKeys.detail(name, true), published) - queryClient.setQueryData(dashboardKeys.detail(name), published) + queryClient.setQueryData(dashboardKeys.detail(id, true), published) + queryClient.setQueryData(dashboardKeys.detail(id), published) queryClient.invalidateQueries({ queryKey: dashboardKeys.all }) }, }) @@ -116,7 +116,8 @@ export function usePublishMessage() { requestBody: { value, source_kind: "dashboard", - // Matches the endpoint id the canvas builds for this widget. + // Matches the endpoint id the canvas builds for this widget, + // `dashboard::`. source_id: dashboard && widget ? `dashboard:${dashboard}:${widget}` : "", source_label: label ?? widget ?? "Dashboard", diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index 78c4b6d..ca03c77 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -867,6 +867,7 @@ function DropdownWidget({ widget, dashboard }: WidgetProps) { // --------------------------------------------------------------------------- +/** `dashboard` is the dashboard's id, which a control credits what it sends to. */ export type WidgetProps = { widget: WidgetDef; dashboard: string } const RENDERERS: Partial< diff --git a/frontend/src/components/Devices/DeviceDetail.tsx b/frontend/src/components/Devices/DeviceDetail.tsx index 8cf8fbe..71a9e57 100644 --- a/frontend/src/components/Devices/DeviceDetail.tsx +++ b/frontend/src/components/Devices/DeviceDetail.tsx @@ -312,14 +312,15 @@ function Dashboards({ }) { const { data } = useQuery(dashboardsQueryOptions()) const known = data?.data ?? [] + // Dashboard ids: a device keeps showing a dashboard whatever it is called. const assigned = device.dashboards ?? [] - const byName = new Map(known.map((dashboard) => [dashboard.name, dashboard])) - const free = known.filter((dashboard) => !assigned.includes(dashboard.name)) + const byId = new Map(known.map((dashboard) => [dashboard.id, dashboard])) + const free = known.filter((dashboard) => !assigned.includes(dashboard.id!)) const set = (dashboards: string[]) => onChange({ dashboards }) const move = (from: number, by: number) => { const next = [...assigned] - const [name] = next.splice(from, 1) - next.splice(from + by, 0, name) + const [id] = next.splice(from, 1) + next.splice(from + by, 0, id) set(next) } @@ -334,13 +335,13 @@ function Dashboards({

) : (
    - {assigned.map((name, index) => { - const dashboard = byName.get(name) - const title = dashboard?.title || name + {assigned.map((id, index) => { + const dashboard = byId.get(id) + const title = dashboard?.title || dashboard?.name || id // What this dashboard draws on the rail. const Glyph = resolveIcon(dashboard?.icon ?? "") return ( -
  1. +
  2. {index + 1} @@ -352,8 +353,8 @@ function Dashboards({ {dashboard ? ( {title} @@ -361,7 +362,7 @@ function Dashboards({ ) : ( // Deleted since: the device skips it. - {name} + {id} )} {canEdit ? ( @@ -391,7 +392,7 @@ function Dashboards({ size="icon-touch" className="text-muted-foreground" aria-label={`Remove ${title}`} - onClick={() => set(assigned.filter((n) => n !== name))} + onClick={() => set(assigned.filter((n) => n !== id))} > @@ -403,7 +404,7 @@ function Dashboards({
)} {canEdit && free.length > 0 ? ( - set([...assigned, id])}> {free.map((dashboard) => ( - + {dashboard.title || dashboard.name} ))} @@ -778,8 +779,8 @@ function Capabilities({ {wired.map((binding) => ( @@ -839,8 +840,8 @@ export function UseInFlow({ const { data } = useQuery(flowsQueryOptions()) const flows = data?.data ?? [] const place = useMutation({ - mutationFn: async (flowName: string) => { - const { definition } = await FlowsService.readFlow({ flowId: flowName }) + mutationFn: async (flowId: string) => { + const { definition } = await FlowsService.readFlow({ flowId }) const nodes = definition.nodes ?? [] const existing = deviceNodeOf(nodes, device.id) const node = existing ?? { @@ -854,7 +855,7 @@ export function UseInFlow({ const placed = port ? withPort(node, port) : node if (placed !== existing) { await FlowsService.saveFlow({ - flowId: flowName, + flowId, requestBody: { ...definition, nodes: existing @@ -863,12 +864,12 @@ export function UseInFlow({ }, }) } - return { flowName, node: placed.id } + return { flowId, node: placed.id } }, - onSuccess: ({ flowName, node }) => + onSuccess: ({ flowId, node }) => navigate({ - to: "/flows/$flowName", - params: { flowName }, + to: "/flows/$flowId", + params: { flowId }, search: { node }, }), onError: (error) => handleError.call(showErrorToast, error as ApiError), @@ -876,7 +877,7 @@ export function UseInFlow({ if (flows.length === 0) return null return ( - place.mutate(flowId)}> {flows.map((flow) => ( - + {flow.title || flow.name} ))} diff --git a/frontend/src/components/Flow/BrainNode.tsx b/frontend/src/components/Flow/BrainNode.tsx index 231a8b9..fba6c36 100644 --- a/frontend/src/components/Flow/BrainNode.tsx +++ b/frontend/src/components/Flow/BrainNode.tsx @@ -10,7 +10,9 @@ export type BrainNodeData = { kind: string /** The `flow.node_id` names behind this neuron; live events key by these. */ members: string[] + /** The keys of the flows it sits in, and their ids in the same order. */ flows: string[] + flowIds: string[] /** Circle diameter in pixels, from how many neurons this one is wired to. */ size: number /** Why this neuron cannot run, if validation found something. */ diff --git a/frontend/src/components/Flow/BrainView.tsx b/frontend/src/components/Flow/BrainView.tsx index 15f1657..fff8972 100644 --- a/frontend/src/components/Flow/BrainView.tsx +++ b/frontend/src/components/Flow/BrainView.tsx @@ -113,6 +113,7 @@ function assemble(graph: BrainGraph, placed: Placed[]): Layout { kind: source.kind, members: source.members ?? [], flows: source.flows ?? [], + flowIds: source.flow_ids ?? [], size: node.size, issue: source.issue, } satisfies BrainNodeData, @@ -233,18 +234,22 @@ function BrainCanvas() { // flows are in the order they contributed one — which is rarely the // one that failed. So the failing member picks the flow, and takes // its node with it; `flow.node_id`, and neither half can hold a dot. - const { members, flows } = node.data as BrainNodeData + // The member names the flow by key; `flowIds` beside `flows` says + // which flow that is, so the address never comes from a key. + const { members, flows, flowIds } = node.data as BrainNodeData const failing = members.find( (member) => liveStore.getFailure(member) || liveStore.getStatus(member)?.status === "error", ) const [memberFlow, nodeId] = (failing ?? "").split(".") - const flowName = memberFlow || flows[0] - if (flowName) + const flowId = memberFlow + ? flowIds[flows.indexOf(memberFlow)] + : flowIds[0] + if (flowId) navigate({ - to: "/flows/$flowName", - params: { flowName }, + to: "/flows/$flowId", + params: { flowId }, search: nodeId ? { node: nodeId } : {}, }) }} diff --git a/frontend/src/components/Flow/CommandPalette.tsx b/frontend/src/components/Flow/CommandPalette.tsx index f685266..23175d4 100644 --- a/frontend/src/components/Flow/CommandPalette.tsx +++ b/frontend/src/components/Flow/CommandPalette.tsx @@ -149,13 +149,13 @@ export function CommandPalette({ {flows.map((flow) => ( close(() => navigate({ - to: "/flows/$flowName", - params: { flowName: flow.name }, + to: "/flows/$flowId", + params: { flowId: flow.id! }, }), ) } diff --git a/frontend/src/components/Flow/EdgeInspector.tsx b/frontend/src/components/Flow/EdgeInspector.tsx index 02a85f1..d04f32d 100644 --- a/frontend/src/components/Flow/EdgeInspector.tsx +++ b/frontend/src/components/Flow/EdgeInspector.tsx @@ -49,11 +49,14 @@ export type InspectedEdge = { export function EdgeInspector({ edge, flow, + flowId, onClose, onUnbind, }: { edge: InspectedEdge | null + /** The flow's key, which its messages are named under. */ flow: string + flowId: string onClose: () => void onUnbind: (message: string) => void }) { @@ -64,7 +67,7 @@ export function EdgeInspector({ const replay = useMutation({ mutationFn: (value: unknown) => FlowsService.triggerNode({ - flowId: flow, + flowId, nodeId: edge?.sourceId ?? "", requestBody: { values: { [edge?.message ?? ""]: value } }, }), @@ -145,6 +148,7 @@ export function EdgeInspector({
diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index 319cd1c..57cd29d 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -258,11 +258,12 @@ function uniqueNodeId(existing: NodeDef_Input[], type: string): string { } function FlowEditorInner({ - flowName, + flowId, focus, onReload, }: { - flowName: string + /** What the route and every API call address it by. */ + flowId: string /** A node or endpoint to arrive on, from the address bar. */ focus?: string onReload: () => void @@ -275,8 +276,11 @@ function FlowEditorInner({ const { resolvedTheme } = useTheme() const { data: flows } = useSuspenseQuery(flowsQueryOptions()) - const { data: detail } = useSuspenseQuery(flowQueryOptions(flowName)) + const { data: detail } = useSuspenseQuery(flowQueryOptions(flowId)) const { data: nodeTypeInfo } = useQuery(nodeTypesQueryOptions()) + // The key: the namespace of the flow's messages, which live values, issues, + // pauses and node ids are all named under. + const flowName = detail.definition.name const { save, @@ -285,9 +289,9 @@ function FlowEditorInner({ resolveConflict, adopt, mutation: saving, - } = useAutosave(flowName) - const publish = usePublish(flowName) - const discard = useDiscardDraft(flowName) + } = useAutosave(flowId) + const publish = usePublish(flowId) + const discard = useDiscardDraft(flowId) // The whole working document, so a flow-level edit is an edit like any // other — undoable, and on screen before the server has seen it. @@ -464,6 +468,7 @@ function FlowEditorInner({ data: { definition: definition ?? { id: node.id }, flow: flowName, + flowId, typeLabel: typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "", isPlugin: pluginTypes.has(definition?.type ?? ""), @@ -477,6 +482,7 @@ function FlowEditorInner({ canvasNodes, definitions, flowName, + flowId, issuesByNode, warningsByNode, pluginTypes, @@ -532,25 +538,28 @@ function FlowEditorInner({ */ const openEndpoint = useCallback( (id: string, via?: string) => { - const [kind, rest] = id.split(":", 2) + const kind = id.split(":", 1)[0] + // Where it lives comes with it: a key in the endpoint's id names + // messages, never an address. Read at the click, so a refetched detail + // does not make this a new callback and re-run the arrival below. + const owner = queryClient + .getQueryData(flowKeys.detail(flowId)) + ?.endpoints?.find((one) => one.id === id)?.owner_id if (kind === "input" || kind === "output") { // This flow's own boundary, declared in its settings. setSelectedId(null) setFlowPanelOpen(true) - } else if (kind === "dashboard") { + } else if (kind === "dashboard" && owner) { + navigate({ to: "/dashboards/$id", params: { id: owner } }) + } else if (kind === "flow" && owner) { navigate({ - to: "/dashboards/$name", - params: { name: (rest ?? "").split(":")[0] }, - }) - } else if (kind === "flow") { - navigate({ - to: "/flows/$flowName", - params: { flowName: (rest ?? "").split(".")[0] }, + to: "/flows/$flowId", + params: { flowId: owner }, search: { node: `flow:${via ? `${flowName}.${via}` : flowName}` }, }) } }, - [navigate, flowName], + [navigate, flowName, flowId, queryClient], ) /** @@ -861,7 +870,7 @@ function FlowEditorInner({ const runMutation = useMutation({ mutationFn: (inputs: Record = {}) => - FlowsService.runFlow({ flowId: flowName, requestBody: { inputs } }), + FlowsService.runFlow({ flowId, requestBody: { inputs } }), onSuccess: (state) => { liveStore.setValues( Object.fromEntries( @@ -892,10 +901,10 @@ function FlowEditorInner({ const enableMutation = useMutation({ mutationFn: (next: boolean) => next - ? FlowsService.startFlow({ flowId: flowName }) - : FlowsService.stopFlow({ flowId: flowName }), + ? FlowsService.startFlow({ flowId }) + : FlowsService.stopFlow({ flowId }), onSuccess: (detail) => { - queryClient.setQueryData(flowKeys.detail(flowName), detail) + queryClient.setQueryData(flowKeys.detail(flowId), detail) queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true }) }, onError: () => showErrorToast("The flow could not be started or stopped."), @@ -904,14 +913,14 @@ function FlowEditorInner({ const pauseMutation = useMutation({ mutationFn: (next: boolean) => next - ? FlowsService.pauseFlow({ flowId: flowName }) - : FlowsService.resumeFlow({ flowId: flowName }), + ? 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."), }) const stepMutation = useMutation({ - mutationFn: () => FlowsService.stepFlow({ flowId: flowName }), + 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."), @@ -919,11 +928,11 @@ function FlowEditorInner({ const deleteMutation = useMutation({ mutationFn: async (withRuns: boolean) => { - await FlowsService.deleteFlow({ flowId: flowName }) + await FlowsService.deleteFlow({ flowId }) // After the flow, which is refused while a run is live, so a refusal // leaves both standing. if (withRuns) - await RunsService.deleteRuns({ flow: flowName, history: true }) + await RunsService.deleteRuns({ flow: flowId, history: true }) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: flowKeys.all }) @@ -947,7 +956,7 @@ function FlowEditorInner({ // draft, so the ports on the canvas have to be there first. await flush() return FlowsService.saveNodeSource({ - flowId: flowName, + flowId, nodeId, requestBody: { code }, drop, @@ -955,7 +964,7 @@ function FlowEditorInner({ }, // One at a time: the save that drops a port must not land before the // autosave that kept it. - scope: { id: `source:${flowName}` }, + scope: { id: `source:${flowId}` }, onSuccess: (saved) => { const followed = saved.node if (followed && saved.version) { @@ -974,7 +983,7 @@ function FlowEditorInner({ ), ) } - queryClient.invalidateQueries({ queryKey: ["flows", flowName] }) + queryClient.invalidateQueries({ queryKey: flowKeys.detail(flowId) }) }, }) @@ -1205,10 +1214,10 @@ function FlowEditorInner({ // Publish what was actually stored: the version only advances once the // queued save has landed. const current = queryClient.getQueryData( - flowKeys.detail(flowName), + flowKeys.detail(flowId), ) publish.mutate(current?.definition.version ?? 1) - }, [flowName, flush, publish.mutate, queryClient]) + }, [flowId, flush, publish.mutate, queryClient]) /** Copy the selected nodes, with the code of the ones carrying their own. */ const copyNodes = useCallback(async () => { @@ -1227,7 +1236,7 @@ function FlowEditorInner({ .filter((node) => !node.source_ref && sourceTypes.has(node.type ?? "")) .map(async (node) => { const { code } = await FlowsService.readNodeSource({ - flowId: flowName, + flowId, nodeId: node.id, }) sources[node.id] = code @@ -1244,7 +1253,7 @@ function FlowEditorInner({ if (!stored) { notify("That is too much to copy. The browser store is full.", "error") } - }, [canvasNodes, definitions, flowName, selectedId, sourceTypes]) + }, [canvasNodes, definitions, flowId, selectedId, sourceTypes]) /** * Paste them here, renamed around whatever this flow already holds. The @@ -1341,6 +1350,8 @@ function FlowEditorInner({ return ( <> + {/* From the document: the id in the address is opaque for a new flow. */} + {`${flowDoc.title || flowName} - Fluksio`} {/* * Grows in from the centre rather than arriving mid-pan, the same * entrance the brain view uses. React Flow reads a node's handle bounds @@ -1531,6 +1542,7 @@ function FlowEditorInner({ setInspected(null)} onUnbind={unbind} /> @@ -1579,6 +1593,7 @@ function FlowEditorInner({ setInspectedInput(null)} onEdit={() => { setInspectedInput(null) @@ -1753,10 +1768,10 @@ function useUnpositionedNodes(definitions: NodeDef_Input[]) { } export function FlowEditor({ - flowName, + flowId, focus, }: { - flowName: string + flowId: string /** A node or endpoint to arrive on — see `FlowEditorInner`. */ focus?: string }) { @@ -1782,8 +1797,8 @@ export function FlowEditor({ * would throw an unsaved edit away with it. */} diff --git a/frontend/src/components/Flow/FlowNode.tsx b/frontend/src/components/Flow/FlowNode.tsx index edd33ae..bcf206e 100644 --- a/frontend/src/components/Flow/FlowNode.tsx +++ b/frontend/src/components/Flow/FlowNode.tsx @@ -92,7 +92,10 @@ const STATUS_STYLES = { export type FlowNodeData = { definition: NodeDef_Input + /** The flow's key, which its node ids and live values are named under. */ flow: string + /** The flow's id, which the API takes. */ + flowId: string typeLabel: string isPlugin?: boolean issueText: string @@ -141,6 +144,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) { const { definition, flow, + flowId, typeLabel, isPlugin, issueText, @@ -278,7 +282,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) { // Best effort by nature: it may well have finished between // the render and the click, which is the outcome asked for. FlowsService.cancelNode({ - flowId: flow, + flowId, nodeId: definition.id, }).catch(() => {}) }} @@ -318,7 +322,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) { onShowLogs(definition.id) // Reading it is what dismisses it: nothing else does, and a // marker that never goes away stops meaning anything. - liveStore.acknowledgeFailure(nodeId) + liveStore.acknowledgeFailure(nodeId, flowId) }} > diff --git a/frontend/src/components/Flow/FlowPanel.tsx b/frontend/src/components/Flow/FlowPanel.tsx index 0bf1f6a..5d062a1 100644 --- a/frontend/src/components/Flow/FlowPanel.tsx +++ b/frontend/src/components/Flow/FlowPanel.tsx @@ -13,7 +13,12 @@ import { } from "@/components/ui/dialog" import { Switch } from "@/components/ui/switch" import { BoundarySections } from "./FlowBoundary" -import { PanelSection, PanelTitle, SidePanel } from "./SidePanel" +import { + IdentitySection, + PanelSection, + PanelTitle, + SidePanel, +} from "./SidePanel" /** * The flow's own settings, in the same panel its nodes use — down to where the @@ -21,6 +26,7 @@ import { PanelSection, PanelTitle, SidePanel } from "./SidePanel" */ export function FlowPanel({ open, + flowId, definition, nodeCount, onChange, @@ -32,6 +38,7 @@ export function FlowPanel({ onClose, }: { open: boolean + flowId: string definition: FlowDef_Input nodeCount: number onChange: (next: FlowDef_Input) => void @@ -77,6 +84,12 @@ export function FlowPanel({ } >
+ + void onEdit: () => void }) { @@ -111,6 +114,7 @@ export function InputInspector({
diff --git a/frontend/src/components/Flow/MessageSparkline.tsx b/frontend/src/components/Flow/MessageSparkline.tsx index dd110ee..b0c64b8 100644 --- a/frontend/src/components/Flow/MessageSparkline.tsx +++ b/frontend/src/components/Flow/MessageSparkline.tsx @@ -59,17 +59,20 @@ function useSettled(value: string): string { */ export function MessageSparkline({ flow, + flowId, name, dtype, }: { + /** The flow's key, which a bare `name` is read under. */ flow: string + flowId: string name: string /** What the port declares, so a value that never arrived still says what it would be. */ dtype?: DType }) { const message = useSettled(name) const live = useLiveValue(qualify(flow, message)) - const { data } = useQuery(messageHistoryQueryOptions(flow, message)) + const { data } = useQuery(messageHistoryQueryOptions(flowId, message)) const [tail, setTail] = useState([]) // Pointing the field at another message makes the collected tail meaningless. diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index 30f5143..fe25ece 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -26,6 +26,7 @@ import { type MessageSpec, type NodeDef_Input, type NodeTypeInfo, + OpenAPI, } from "@/client" import { DEVICE_TYPE, @@ -81,6 +82,7 @@ import { useParamSuggestions, } from "./queries" import { + CopyValue, FieldLabel, InfoTip, PANEL_SECTION, @@ -307,6 +309,7 @@ function PortList({ side, specs, flow, + flowId, emptyHint, suggestions, onChange, @@ -318,7 +321,9 @@ function PortList({ /** An input reads a message into the code, an output writes one out of it. */ side: "requires" | "provides" specs: MessageSpec[] + /** The flow's key, which a bare message name is read under. */ flow: string + flowId: string emptyHint: string suggestions: string[] onChange: (next: MessageSpec[]) => void @@ -579,6 +584,7 @@ function PortList({ {spec.name ? ( @@ -1616,17 +1622,69 @@ function ResourcesSection({ ) } +/** + * Where a webhook or a socket this node serves is reached: a path in its `url` + * is served under the flow's id, so a URL handed out survives a new key. + * + * A stored secret stays in the secrets store, so the URL names it instead of + * carrying it, and that last segment is for whoever uses it to fill in. + */ +function HookSection({ + flowId, + node, +}: { + flowId: string + node: NodeDef_Input +}) { + const path = String(node.params?.url ?? "") + if (!["http", "websocket"].includes(node.type ?? "") || !path.startsWith("/")) + return null + const secret = node.params?.secret + const plain = typeof secret === "string" ? secret : "" + const stored = + secret && typeof secret === "object" + ? (secret as { $secret?: string }).$secret + : undefined + // Concatenated, as for the socket: under a portal the base has a path. + const base = String(OpenAPI.BASE || window.location.origin).replace(/\/$/, "") + const http = `${base}/hooks/${flowId}/${path.replace(/^\/+/, "")}${ + plain ? `/${plain}` : "" + }` + const url = node.type === "websocket" ? http.replace(/^http/, "ws") : http + const what = node.type === "websocket" ? "socket URL" : "webhook URL" + const title = what[0].toUpperCase() + what.slice(1) + + return ( +
+
+ {title} + + {stored + ? `Served while the flow runs. Append the value of the secret ${stored} as the last segment.` + : "Served while the flow runs. It stays the same if the flow's key changes."} + +
+ + {url} + {stored ? ( + /{`<${stored}>`} + ) : null} + +
+ ) +} + /** * Sharing a node moves its code to the library, where other flows can point at * it. Each flow keeps its own ports and settings; only the code is common, so * one fix reaches all of them. */ function SharingSection({ - flow, + flowId, node, onShared, }: { - flow: string + flowId: string node: NodeDef_Input onShared: () => void }) { @@ -1640,14 +1698,14 @@ function SharingSection({ const done = () => { queryClient.invalidateQueries({ queryKey: flowKeys.library }) - queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow) }) + queryClient.invalidateQueries({ queryKey: flowKeys.detail(flowId) }) onShared() } const share = useMutation({ mutationFn: (libName: string) => FlowsService.shareNode({ - flowId: flow, + flowId, nodeId: node.id, requestBody: { lib_name: libName }, }), @@ -1657,8 +1715,7 @@ function SharingSection({ }) const unshare = useMutation({ - mutationFn: () => - FlowsService.unshareNode({ flowId: flow, nodeId: node.id }), + mutationFn: () => FlowsService.unshareNode({ flowId, nodeId: node.id }), onSuccess: done, onError: () => showErrorToast("The node could not be unshared."), }) @@ -1736,27 +1793,27 @@ const percent = (value: unknown) => * it is an edit like any other, so it waits in the draft until publishing. */ function TrainingSection({ - flow, + flowId, node, onUse, }: { - flow: string + flowId: string node: NodeDef_Input onUse: (model: string) => void }) { const queryClient = useQueryClient() // Why it would not start, such as wren's training flow not being registered. const [problem, setProblem] = useState(null) - const queryKey = ["wren-training", flow, node.id] + const queryKey = ["wren-training", flowId, node.id] const { data: training } = useQuery({ queryKey, - queryFn: () => FlowsService.readTraining({ flowId: flow, nodeId: node.id }), + queryFn: () => FlowsService.readTraining({ flowId, nodeId: node.id }), // Training takes minutes, so only a run still going is read again. refetchInterval: (query) => query.state.data && isLive(query.state.data.status) ? 5_000 : false, }) const train = useMutation({ - mutationFn: () => FlowsService.trainNode({ flowId: flow, nodeId: node.id }), + mutationFn: () => FlowsService.trainNode({ flowId, nodeId: node.id }), onMutate: () => setProblem(null), onSuccess: () => queryClient.invalidateQueries({ queryKey }), onError: handleError.bind(setProblem), @@ -2030,6 +2087,7 @@ function scaffoldFor(node: NodeDef_Input): string { function PanelBody({ node, flow, + flowId, nodeType, origin, suggestions, @@ -2043,6 +2101,7 @@ function PanelBody({ }: { node: NodeDef_Input flow: string + flowId: string nodeType: NodeTypeInfo | undefined origin: FlowOrigin | null | undefined suggestions: PortSuggestions @@ -2057,7 +2116,7 @@ function PanelBody({ }) { const hasSource = nodeType?.has_source ?? node.type === "python" const { data: source } = useQuery({ - ...nodeSourceQueryOptions(flow, node.id), + ...nodeSourceQueryOptions(flowId, node.id), enabled: hasSource, }) // Code the engine runs is a superuser's to change; the server refuses @@ -2228,6 +2287,7 @@ function PanelBody({ side="requires" specs={node.requires ?? []} flow={flow} + flowId={flowId} emptyHint="Nothing yet. Add a message this node reads." suggestions={suggestions.consumes} onChange={(requires) => editNode({ ...node, requires })} @@ -2237,6 +2297,7 @@ function PanelBody({ side="provides" specs={node.provides ?? []} flow={flow} + flowId={flowId} emptyHint="Nothing yet. Add a message this node publishes." suggestions={suggestions.provides} onChange={(provides) => { @@ -2270,6 +2331,7 @@ function PanelBody({ ) : ( paramsForm )} + {nodeType?.free_params ? ( editNode({ ...node, params: { ...node.params, model } }) @@ -2325,7 +2387,7 @@ function PanelBody({ ) : null} {hasSource ? ( - + ) : null}
@@ -2405,6 +2467,7 @@ export type PortSuggestions = { consumes: string[]; provides: string[] } export function NodePanel({ node, flow, + flowId, nodeTypes, origin, suggestions, @@ -2419,7 +2482,9 @@ export function NodePanel({ onDelete, }: { node: NodeDef_Input | null + /** The flow's key, the namespace its messages are named under. */ flow: string + flowId: string nodeTypes: NodeTypeInfo[] /** Set when the flow was declared in code elsewhere; its bodies are generated. */ origin: FlowOrigin | null | undefined @@ -2476,6 +2541,7 @@ export function NodePanel({ + + {children ?? value} + + {navigator.clipboard ? ( + + ) : null} +
+ ) +} + +/** + * The key a flow or dashboard goes by and the id it is addressed by. The id + * never changes, so it is the one worth copying into a script or a link. + */ +export function IdentitySection({ + name, + id, + help, +}: { + name: string + id: string + help: ReactNode +}) { + return ( + +
+
+
Key
+
+ {name} +
+
+
+
Id
+
+ +
+
+
+
+ ) +} + /** * What the thing in this panel is called, and the one place to rename it. * diff --git a/frontend/src/components/Flow/liveStore.ts b/frontend/src/components/Flow/liveStore.ts index fe95206..160a104 100644 --- a/frontend/src/components/Flow/liveStore.ts +++ b/frontend/src/components/Flow/liveStore.ts @@ -201,18 +201,19 @@ export const liveStore = { * not, which is what makes a failure between two glances at the canvas * findable at all. */ - acknowledgeFailure(nodeId: string) { + acknowledgeFailure(nodeId: string, flowId: string) { if (!failures.delete(nodeId)) return notify(`failure:${nodeId}`) - const [flow, node] = nodeId.split(".") - if (!flow || !node) return + // `key.node`: the key names the node, the id addresses the flow. + const node = nodeId.split(".")[1] + if (!node) return // Hand-written rather than generated: nothing hangs off the response, and // a call that does not arrive only means the marker is back after a // reload — which is the safer way round for a failure. request(OpenAPI, { method: "POST", - url: "/api/v1/flows/{name}/nodes/{node_id}/acknowledge", - path: { name: flow, node_id: node }, + url: "/api/v1/flows/{flow_id}/nodes/{node_id}/acknowledge", + path: { flow_id: flowId, node_id: node }, }).catch(() => {}) }, recordEngineEvent(event: EngineEvent) { diff --git a/frontend/src/components/Flow/queries.ts b/frontend/src/components/Flow/queries.ts index d87dda9..1d95895 100644 --- a/frontend/src/components/Flow/queries.ts +++ b/frontend/src/components/Flow/queries.ts @@ -15,13 +15,14 @@ import { SecretsService, } from "@/client" +/** Keyed by flow id, which every flow route takes. */ export const flowKeys = { all: ["flows"] as const, - detail: (name: string) => ["flows", name] as const, - source: (name: string, nodeId: string) => - ["flows", name, "source", nodeId] as const, - history: (name: string, message: string) => - ["flows", name, "history", message] as const, + detail: (id: string) => ["flows", id] as const, + source: (id: string, nodeId: string) => + ["flows", id, "source", nodeId] as const, + history: (id: string, message: string) => + ["flows", id, "history", message] as const, nodeTypes: ["flows", "node-types"] as const, library: ["flows", "library"] as const, // Under `all` on purpose: the socket invalidates that prefix on every @@ -34,9 +35,9 @@ export const flowsQueryOptions = () => ({ queryFn: () => FlowsService.readFlows(), }) -export const flowQueryOptions = (name: string) => ({ - queryKey: flowKeys.detail(name), - queryFn: () => FlowsService.readFlow({ flowId: name }), +export const flowQueryOptions = (id: string) => ({ + queryKey: flowKeys.detail(id), + queryFn: () => FlowsService.readFlow({ flowId: id }), }) /** Names of the stored secrets, for pointing a credential parameter at one. */ @@ -69,15 +70,15 @@ export const nodeTypesQueryOptions = () => ({ staleTime: Number.POSITIVE_INFINITY, }) -export const nodeSourceQueryOptions = (name: string, nodeId: string) => ({ - queryKey: flowKeys.source(name, nodeId), - queryFn: () => FlowsService.readNodeSource({ flowId: name, nodeId }), +export const nodeSourceQueryOptions = (id: string, nodeId: string) => ({ + queryKey: flowKeys.source(id, nodeId), + queryFn: () => FlowsService.readNodeSource({ flowId: id, nodeId }), }) /** The recent values of one message, for the panel's sparkline. */ -export const messageHistoryQueryOptions = (name: string, message: string) => ({ - queryKey: flowKeys.history(name, message), - queryFn: () => FlowsService.readMessageHistory({ flowId: name, message }), +export const messageHistoryQueryOptions = (id: string, message: string) => ({ + queryKey: flowKeys.history(id, message), + queryFn: () => FlowsService.readMessageHistory({ flowId: id, message }), }) /** Other flows change rarely; suggestions do not need them fresh to the second. */ @@ -103,7 +104,7 @@ export function useParamSuggestions( // entries; an aggregate endpoint if a big instance makes that hurt. const details = useQueries({ queries: (type ? (flows?.data ?? []) : []).map((flow) => ({ - ...flowQueryOptions(flow.name), + ...flowQueryOptions(flow.id!), staleTime: SUGGEST_STALE, })), }) @@ -130,25 +131,25 @@ const AUTOSAVE_DELAY = 800 const RETRY_DELAY = 100 /** Publish the unpublished changes, which is what puts them on the engine. */ -export function usePublish(name: string) { +export function usePublish(id: string) { const queryClient = useQueryClient() return useMutation({ mutationFn: (version: number) => - FlowsService.publishFlow({ flowId: name, requestBody: { version } }), + FlowsService.publishFlow({ flowId: id, requestBody: { version } }), onSuccess: (detail) => { - queryClient.setQueryData(flowKeys.detail(name), detail) + queryClient.setQueryData(flowKeys.detail(id), detail) queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true }) }, }) } /** Throw the unpublished changes away and go back to what is running. */ -export function useDiscardDraft(name: string) { +export function useDiscardDraft(id: string) { const queryClient = useQueryClient() return useMutation({ - mutationFn: () => FlowsService.discardDraft({ flowId: name }), + mutationFn: () => FlowsService.discardDraft({ flowId: id }), onSuccess: (detail) => { - queryClient.setQueryData(flowKeys.detail(name), detail) + queryClient.setQueryData(flowKeys.detail(id), detail) queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true }) }, }) @@ -166,7 +167,7 @@ export function useDiscardDraft(name: string) { * between, the server refuses rather than discarding their work, and `conflict` * turns true for the editor to ask which version wins. */ -export function useAutosave(name: string): { +export function useAutosave(id: string): { save: (definition: FlowDef_Input) => void /** Send what is queued and resolve once the server has it. */ flush: () => Promise @@ -189,7 +190,7 @@ export function useAutosave(name: string): { const mutation = useMutation({ mutationFn: (definition: FlowDef_Input) => FlowsService.saveFlow({ - flowId: name, + flowId: id, // The ref holds the freshest version this client has seen; before the // first save that is the one the document was loaded with. Sending // `undefined` would drop the field and let the server read the default, @@ -210,7 +211,7 @@ export function useAutosave(name: string): { 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. - queryClient.setQueryData(flowKeys.detail(name), detail) + queryClient.setQueryData(flowKeys.detail(id), detail) queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true }) }, onError: (error) => { @@ -250,17 +251,17 @@ export function useAutosave(name: string): { sent.current = null version.current = null await queryClient.invalidateQueries({ - queryKey: flowKeys.detail(name), + queryKey: flowKeys.detail(id), }) return } - const current = await FlowsService.readFlow({ flowId: name }) + const current = await FlowsService.readFlow({ flowId: id }) version.current = current.definition.version ?? null const definition = pending.current ?? sent.current pending.current = null if (definition) mutate(definition) }, - [mutate, name, queryClient], + [mutate, id, queryClient], ) const save = useCallback( diff --git a/frontend/src/components/Flow/useFlowSocket.ts b/frontend/src/components/Flow/useFlowSocket.ts index 187bf99..11618da 100644 --- a/frontend/src/components/Flow/useFlowSocket.ts +++ b/frontend/src/components/Flow/useFlowSocket.ts @@ -35,6 +35,11 @@ const AUTH_ATTEMPTS = 3 */ const HEALTH_FLOOR = 60_000 +/** + * An engine event names its flow by key (`flow`), which node ids and live + * values are qualified with, and by id (`flow_id`), which a query or a route + * takes. Snapshot lists stay keyed by key. + */ type FlowEvent = | { type: "snapshot" @@ -58,6 +63,7 @@ type FlowEvent = | { type: "node_queued" flow?: string + flow_id?: string node: string run?: string detail?: string @@ -66,6 +72,7 @@ type FlowEvent = | { type: "node_executed" flow?: string + flow_id?: string node: string outputs: number duration_ms?: number @@ -73,6 +80,7 @@ type FlowEvent = | { type: "node_error" flow?: string + flow_id?: string node: string error: string ts?: number @@ -80,21 +88,29 @@ type FlowEvent = | { type: "node_status"; node: string; status: string; error?: string | null } | { type: "node_error_acknowledged"; node: string; ts?: number } | ({ type: "node_log" } & LogLine) - | { type: "flow_paused"; flow: string; paused: boolean } + | { type: "flow_paused"; flow: string; flow_id?: string; paused: boolean } | { type: "node_health" flow?: string + flow_id?: string node: string health: "ok" | "degraded" | "down" | "unknown" detail?: string | null ts?: number } - | { type: "flow_quarantined"; flow: string; error?: string; ts?: number } + | { + type: "flow_quarantined" + flow: string + flow_id?: string + error?: string + ts?: number + } | { type: "engine_degraded"; reason?: string; ts?: number } | { type: "engine_fatal"; reason?: string; ts?: number } | { type: "cascade_dropped" flow?: string + flow_id?: string node?: string deliveries?: number ts?: number @@ -102,6 +118,7 @@ type FlowEvent = | { type: "queue_unavailable" flow?: string + flow_id?: string node?: string error?: string ts?: number @@ -111,6 +128,7 @@ type FlowEvent = nodes: { id: string; status: string; error?: string | null }[] paused?: string[] } + /** `dashboard` is an id, or empty when only device assignments changed. */ | { type: "dashboard_changed"; dashboard?: string; ts?: number } | { type: "device_status" @@ -122,6 +140,7 @@ type FlowEvent = | { type: "run_started" | "run_finished" flow: string + flow_id?: string run: string status?: string group?: string @@ -130,6 +149,7 @@ type FlowEvent = | { type: "run_metric" flow: string + flow_id?: string run: string names: string[] ts?: number @@ -231,11 +251,11 @@ function endLive() { } /** Everything a node's health shows up in, which is two unrelated keys. */ -function invalidateHealth(flow: string | undefined) { +function invalidateHealth(flowId: string | undefined) { // `graph` is a sibling of the flow's own key rather than a child, so the // detail invalidation alone never reaches Home's fault rings. for (const queryKey of [ - flow ? flowKeys.detail(flow) : flowKeys.all, + flowId ? flowKeys.detail(flowId) : flowKeys.all, flowKeys.graph, ]) { client?.invalidateQueries({ queryKey }) @@ -387,7 +407,7 @@ function open(url: string) { // the server derives — so the screen only moved on mount, navigation // or a rebuild, never when health actually flipped. The store had a // health map of its own and nothing ever read it. - invalidateHealth(message.flow) + invalidateHealth(message.flow_id) // Degraded is held back by the same floor, as a warning rather than // a fault. if (message.health === "down" || message.health === "degraded") { @@ -402,7 +422,7 @@ function open(url: string) { message.node, setTimeout(() => { healthTimers.delete(message.node) - invalidateHealth(message.flow) + invalidateHealth(message.flow_id) }, HEALTH_FLOOR), ) } diff --git a/frontend/src/components/Health/FlowTable.tsx b/frontend/src/components/Health/FlowTable.tsx index 88640ca..a65e5f9 100644 --- a/frontend/src/components/Health/FlowTable.tsx +++ b/frontend/src/components/Health/FlowTable.tsx @@ -37,6 +37,7 @@ export function FlowTable({ range }: { range: Range }) { const { data: rollups } = useQuery(flowRollupsQueryOptions(range)) const flows = [...(data?.data ?? [])].sort(byRecency) + // Rollups name the flow by id, as every list here is keyed. const activity = new Map( (rollups ?? []).map((row: FlowRollup) => [row.flow, row]), ) @@ -104,9 +105,9 @@ export function FlowTable({ range }: { range: Range }) { {flows.map((flow) => ( ))} @@ -132,11 +133,11 @@ function Row({ const toggle = useMutation({ mutationFn: (next: boolean) => next - ? FlowsService.startFlow({ flowId: flow.name }) - : FlowsService.stopFlow({ flowId: flow.name }), + ? FlowsService.startFlow({ flowId: flow.id! }) + : FlowsService.stopFlow({ flowId: flow.id! }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: flowKeys.all }) - queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow.name) }) + queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow.id!) }) }, onError: () => showErrorToast("The flow could not be started or stopped."), }) @@ -151,8 +152,8 @@ function Row({ sizes to the longest name and pushes the page sideways. */}

{flow.title || flow.name}

diff --git a/frontend/src/components/Health/HealthActivity.tsx b/frontend/src/components/Health/HealthActivity.tsx index 6386111..0287dd8 100644 --- a/frontend/src/components/Health/HealthActivity.tsx +++ b/frontend/src/components/Health/HealthActivity.tsx @@ -190,7 +190,7 @@ function Failure({ event }: { event: EventRow }) { the card, and the rest is behind the chevron anyway. */} - {event.node || event.flow || "engine"} + {event.node || event.flow_name || event.flow || "engine"} {first} @@ -334,7 +334,7 @@ export function HealthActivity({ range }: { range: Range }) { className="flex items-baseline gap-3 border-b border-border py-2 text-sm last:border-0" > - {run.flow} + {run.flow_name || run.flow} {/* Five fixed columns do not fit a phone. What caused a run is the one a narrow row can do without — the status, the @@ -439,7 +439,7 @@ export function HealthActivity({ range }: { range: Range }) { {event.actor} {event.detail} {event.flow ? ( - {event.flow} + {event.flow_name || event.flow} ) : null} diff --git a/frontend/src/components/Runs/NewRun.tsx b/frontend/src/components/Runs/NewRun.tsx index 4e7394a..c95072e 100644 --- a/frontend/src/components/Runs/NewRun.tsx +++ b/frontend/src/components/Runs/NewRun.tsx @@ -70,13 +70,14 @@ export function NewRun({ const counts = new Map((overview ?? []).map((row) => [row.flow, row])) const rows = (flows?.data ?? []).map((one) => { - const seen = counts.get(one.name) + const seen = counts.get(one.id!) const notes = [ one.mode === "batch" ? "batch" : "", one.has_draft ? "draft" : "", ].filter(Boolean) return { - flow: one.name, + flow: one.id!, + flow_name: one.name, runs: seen?.runs ?? 0, running: seen?.running ?? 0, queued: seen?.queued ?? 0, @@ -106,7 +107,7 @@ export function NewRun({
{flow && (