Rework the dashboards onto the flow canvas

One shell for both editors. Flows and dashboards each get a searchable
overview under the padded shell, their editors move to the full-bleed
canvas, and the floating chrome is shared: a title bar that only says
what you are looking at, and a bottom dock carrying everything else —
the flow bar's status, settings and Publish moved down there, the
add-flow button moved to the overview.

Dashboards gain the rest of M4's visualization work:

- widgets are picked by clicking them, with the header as the drag
  handle so a slider still slides and a switch still flips while
  editing; settings moved into the flows' SidePanel
- react-grid-layout for drag and edge-resize, so the stored x/y finally
  mean something; a dashboard nobody arranged is shelf-packed once
- a per-dashboard grid size, so a panel can be matched to its screen
- the chart widget, drawn with uPlot: several messages on one axis, fed
  from the stored history plus the live socket tail, coloured from the
  new --chart-1..5 ramp
- /view/{name}: the URL a wall panel is pointed at — no sidebar, no
  footer, no editing, and no editor code, since routes are split
- a widget wired to a payload type it cannot carry, or wired to nothing
  at all, carries the same red dot a failing node does; the picker
  records the type it bound and WidgetDef refuses a mismatch on save

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
2026-08-16 17:13:10 +02:00
co-authored by Claude Fable 5
parent 74bb956805
commit 0af09eedbe
24 changed files with 1799 additions and 1558 deletions
@@ -0,0 +1,38 @@
import { useQuery } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { DashboardEditor } from "@/components/Dashboard/DashboardEditor"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
type Search = { edit?: boolean }
export const Route = createFileRoute("/_canvas/dashboards/$name")({
component: DashboardRoute,
// Edit mode is a search param so viewing stays the default a panel opens
// into, and an editing session is a link someone can send.
validateSearch: (search: Record<string, unknown>): Search => ({
edit: search.edit === true || search.edit === "true" || undefined,
}),
head: ({ params }) => ({ meta: [{ title: `${params.name} - Fluksio` }] }),
})
function DashboardRoute() {
const { name } = Route.useParams()
const { edit } = Route.useSearch()
// Widgets read live values; the canvas shell has no socket of its own.
useFlowSocket()
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
if (!dashboard) return null
return (
<DashboardEditor
// Leaving edit mode drops the draft, so the session starts clean.
key={`${name}:${edit ? "edit" : "view"}`}
dashboard={dashboard as Dashboard}
edit={Boolean(edit)}
/>
)
}
@@ -1,84 +0,0 @@
import {
useMutation,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query"
import { createFileRoute, Navigate, useNavigate } from "@tanstack/react-router"
import { Workflow } from "lucide-react"
import { Suspense } from "react"
import { FlowsService } from "@/client"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
export const Route = createFileRoute("/_canvas/flows/")({
component: () => (
<Suspense fallback={<Loading />}>
<FlowsIndex />
</Suspense>
),
head: () => ({ meta: [{ title: "Flows - Fluksio" }] }),
})
function Loading() {
return (
<div className="flex h-full items-center justify-center">
<Skeleton className="h-32 w-64 rounded-lg" />
</div>
)
}
function FlowsIndex() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: flows } = useSuspenseQuery(flowsQueryOptions())
const create = useMutation({
mutationFn: () =>
FlowsService.saveFlow({
name: "my_first_flow",
requestBody: { name: "my_first_flow", nodes: [], inputs: [] },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
navigate({
to: "/flows/$flowName",
params: { flowName: "my_first_flow" },
})
},
})
// With flows around, open the first one rather than showing an empty page.
if (flows.data.length > 0) {
return (
<Navigate
to="/flows/$flowName"
params={{ flowName: flows.data[0].name }}
replace
/>
)
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-4 text-center">
<span className="flex size-16 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Workflow className="size-7" />
</span>
<div className="grid gap-1">
<h1 className="font-display text-lg font-medium">No flows yet</h1>
<p className="max-w-sm text-sm text-muted-foreground">
A flow is a handful of nodes passing messages to each other. Start
with one and add nodes as you go.
</p>
</div>
<Button
onClick={() => create.mutate()}
disabled={create.isPending}
data-testid="create-first-flow"
>
{create.isPending ? "Creating…" : "Create a flow"}
</Button>
</div>
)
}
@@ -1,111 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { Check, Pencil, Trash2 } from "lucide-react"
import { useEffect, useState } from "react"
import { type DashboardDef_Output, DashboardsService } from "@/client"
import { DashboardEditor } from "@/components/Dashboard/DashboardEditor"
import { DashboardView, pagesOf } from "@/components/Dashboard/DashboardView"
import {
dashboardKeys,
dashboardQueryOptions,
} from "@/components/Dashboard/queries"
import { Button } from "@/components/ui/button"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
type Search = { edit?: boolean }
export const Route = createFileRoute("/_layout/dashboards/$name")({
component: Dashboard,
// Edit mode is a search param so viewing stays the default a panel opens
// into, and an editing session is a link someone can send.
validateSearch: (search: Record<string, unknown>): Search => ({
edit: search.edit === true || search.edit === "true" || undefined,
}),
})
function Dashboard() {
const { name } = Route.useParams()
const { edit } = Route.useSearch()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
const [pageId, setPageId] = useState<string | undefined>(undefined)
useEffect(() => {
if (dashboard && !pageId) setPageId(pagesOf(dashboard)[0]?.id)
}, [dashboard, pageId])
const remove = useMutation({
mutationFn: () => DashboardsService.deleteDashboard({ name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
navigate({ to: "/dashboards" })
},
onError: handleError.bind(showErrorToast),
})
if (!dashboard) return null
const setEdit = (next: boolean) =>
navigate({
to: "/dashboards/$name",
params: { name },
search: next ? { edit: true } : {},
})
return (
<div className="grid gap-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl">{dashboard.title || dashboard.name}</h1>
<div className="flex items-center gap-2">
{edit ? (
<Button
variant="secondary"
onClick={() => remove.mutate()}
data-testid="delete-dashboard"
>
<Trash2 />
Delete
</Button>
) : null}
<Button
variant={edit ? "brand" : "secondary"}
onClick={() => setEdit(!edit)}
data-testid="toggle-edit"
>
{edit ? <Check /> : <Pencil />}
{edit ? "Done" : "Edit"}
</Button>
</div>
</div>
{pagesOf(dashboard).length > 1 ? (
<Tabs value={pageId} onValueChange={setPageId}>
<TabsList>
{pagesOf(dashboard).map((page) => (
<TabsTrigger key={page.id} value={page.id}>
{page.title || page.id}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{edit ? (
<DashboardEditor
dashboard={dashboard as DashboardDef_Output}
pageId={pageId}
/>
) : (
<DashboardView
dashboard={dashboard as DashboardDef_Output}
pageId={pageId}
/>
)}
</div>
)
}
@@ -23,6 +23,7 @@ function Dashboards() {
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
const [name, setName] = useState("")
const [search, setSearch] = useState("")
const create = useMutation({
mutationFn: (dashboard: string) =>
@@ -38,12 +39,15 @@ function Dashboards() {
onError: handleError.bind(showErrorToast),
})
const dashboards = data?.data ?? []
// The store only accepts this shape, so say so before the request does.
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
const needle = search.trim().toLowerCase()
const dashboards = (data?.data ?? []).filter((dashboard) =>
`${dashboard.name} ${dashboard.title ?? ""}`.toLowerCase().includes(needle),
)
return (
<div className="grid gap-6">
@@ -54,34 +58,46 @@ function Dashboards() {
</p>
</div>
<form
className="flex max-w-md items-center gap-2"
onSubmit={(event) => {
event.preventDefault()
if (slug) create.mutate(slug)
}}
>
<div className="flex flex-wrap items-center gap-2">
<Input
value={name}
placeholder="New dashboard"
aria-label="New dashboard name"
data-testid="new-dashboard-name"
onChange={(event) => setName(event.target.value)}
value={search}
placeholder="Search dashboards"
aria-label="Search dashboards"
className="max-w-xs"
data-testid="search-dashboards"
onChange={(event) => setSearch(event.target.value)}
/>
<Button
type="submit"
variant="secondary"
disabled={!slug || create.isPending}
data-testid="create-dashboard"
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault()
if (slug) create.mutate(slug)
}}
>
<Plus />
Create
</Button>
</form>
<Input
value={name}
placeholder="New dashboard"
aria-label="New dashboard name"
data-testid="new-dashboard-name"
onChange={(event) => setName(event.target.value)}
/>
<Button
type="submit"
variant="secondary"
disabled={!slug || create.isPending}
data-testid="create-dashboard"
>
<Plus />
Create
</Button>
</form>
</div>
{dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
No dashboards yet. Name one above to start.
{needle
? "No dashboard matches that."
: "No dashboards yet. Name one above to start."}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
+133
View File
@@ -0,0 +1,133 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { Plus, Workflow } from "lucide-react"
import { useState } from "react"
import { FlowsService } from "@/client"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
export const Route = createFileRoute("/_layout/flows/")({
component: Flows,
head: () => ({ meta: [{ title: "Flows - Fluksio" }] }),
})
/** The shape the store accepts, so a bad name is caught before the request. */
const NAME = /^[a-z][a-z0-9_]*$/
function Flows() {
const { data } = useQuery(flowsQueryOptions())
const queryClient = useQueryClient()
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
const [name, setName] = useState("")
const [search, setSearch] = useState("")
const create = useMutation({
mutationFn: (flow: string) =>
FlowsService.saveFlow({
name: flow,
requestBody: { name: flow, nodes: [], inputs: [] },
}),
onSuccess: (_saved, flow) => {
queryClient.invalidateQueries({ queryKey: flowKeys.all })
navigate({ to: "/flows/$flowName", params: { flowName: flow } })
},
onError: handleError.bind(showErrorToast),
})
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
const needle = search.trim().toLowerCase()
const flows = (data?.data ?? []).filter((flow) =>
`${flow.name} ${flow.title ?? ""}`.toLowerCase().includes(needle),
)
return (
<div className="grid gap-6">
<div className="grid gap-1">
<h1 className="text-2xl">Flows</h1>
<p className="text-sm text-muted-foreground">
A flow is a handful of nodes passing messages to each other. Keep them
small and name them after what they do.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
value={search}
placeholder="Search flows"
aria-label="Search flows"
className="max-w-xs"
data-testid="search-flows"
onChange={(event) => setSearch(event.target.value)}
/>
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault()
if (NAME.test(slug)) create.mutate(slug)
}}
>
<Input
value={name}
placeholder="New flow"
aria-label="New flow name"
data-testid="new-flow-name"
onChange={(event) => setName(event.target.value)}
/>
<Button
type="submit"
variant="secondary"
disabled={!NAME.test(slug) || create.isPending}
data-testid="create-flow"
>
<Plus />
Create
</Button>
</form>
</div>
{flows.length === 0 ? (
<p className="text-sm text-muted-foreground">
{needle
? "No flow matches that."
: "No flows yet. Name one above to start."}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{flows.map((flow) => (
<Link
key={flow.name}
to="/flows/$flowName"
params={{ flowName: flow.name }}
className="grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors hover:bg-accent/50"
data-testid="flow-card"
>
<span className="flex items-center gap-2">
<Workflow className="size-4 text-muted-foreground" />
{flow.title || flow.name}
{flow.has_draft ? (
<span className="size-1.5 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
</span>
<span className="text-sm text-muted-foreground">
{flow.node_count ?? 0} node
{flow.node_count === 1 ? "" : "s"} ·{" "}
{flow.enabled === false ? "stopped" : "running"}
{flow.error_count ? ` · ${flow.error_count} to fix` : ""}
</span>
</Link>
))}
</div>
)}
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { DashboardView } from "@/components/Dashboard/DashboardView"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
import { isLoggedIn } from "@/hooks/useAuth"
/**
* What a wall panel is pointed at.
*
* Deliberately outside both shells: no sidebar, no footer, no editing, and no
* column cap — the widgets are the whole page. Route splitting means a panel
* never downloads the editor or the grid library either.
*/
export const Route = createFileRoute("/view/$name")({
component: PanelView,
beforeLoad: async () => {
if (!isLoggedIn()) {
throw redirect({ to: "/login" })
}
},
head: ({ params }) => ({ meta: [{ title: `${params.name} - Fluksio` }] }),
})
function PanelView() {
const { name } = Route.useParams()
useFlowSocket()
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
return (
<main className="dot-canvas min-h-svh w-full overflow-y-auto p-4">
{dashboard ? <DashboardView dashboard={dashboard as Dashboard} /> : null}
</main>
)
}