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
Binary file not shown.
@@ -1,45 +1,78 @@
import { useQuery } from "@tanstack/react-query"
import { ChevronLeft, ChevronRight, Plus, X } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import type { ApiError, DashboardDef_Output, WidgetDef } from "@/client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
Check,
ExternalLink,
Loader2,
Pencil,
Plus,
Settings2,
} from "lucide-react"
import { motion } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { GridLayout, type Layout, useContainerWidth } from "react-grid-layout"
import "react-grid-layout/css/styles.css"
import {
type ApiError,
type DashboardDef_Output,
DashboardsService,
type Placement,
type WidgetDef,
} from "@/client"
import { CanvasTitle } from "@/components/Flow/CanvasTitle"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import useCustomToast from "@/hooks/useCustomToast"
import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { handleError } from "@/utils"
import {
columnsOf,
type Dashboard,
DashboardView,
GRID_GAP,
isPlaced,
pagesOf,
SectionGrid,
placement,
ROW_HEIGHT,
sectionsOf,
widgetStyle,
widgetsOf,
} from "./DashboardView"
import { messageCatalogQueryOptions, useSaveDashboard } from "./queries"
import { DashboardPanel, WidgetPanel } from "./panels"
import { dashboardKeys, useSaveDashboard } from "./queries"
import {
INPUT_WIDGETS,
WIDGET_LABELS,
WIDGET_SIZES,
WidgetBody,
WidgetFrame,
type WidgetKind,
widgetIssue,
} from "./widgets"
// Charts are stored and validated, but not drawn yet, so they are not offered.
const KINDS = (Object.keys(WIDGET_LABELS) as WidgetKind[]).filter(
(kind) => kind !== "chart",
)
const KINDS = Object.keys(WIDGET_LABELS) as WidgetKind[]
/** How long to sit on edits before saving, so typing is not a save per key. */
const AUTOSAVE_MS = 800
/**
* Controls a widget owns. A press on one of these is the widget's own — a
* slider still slides in edit mode — so it never counts as picking the widget.
*/
const INTERACTIVE =
"button, a, input, select, textarea, [role='switch'], [role='combobox'], [role='slider'], .react-resizable-handle"
function nextId(dashboard: DashboardDef_Output, type: string): string {
const taken = new Set(
pagesOf(dashboard).flatMap((page) =>
@@ -53,15 +86,82 @@ function nextId(dashboard: DashboardDef_Output, type: string): string {
return candidate
}
/**
* Positions for a dashboard nobody has arranged yet.
*
* Widths were the only thing the old editor set, so every widget sits at 0,0.
* Shelf-packing them into the grid is what the browser was doing implicitly;
* writing it down is what lets them be dragged from there.
*/
function packed(widgets: WidgetDef[], columns: number): Layout {
let x = 0
let y = 0
let shelf = 0
return widgets.map((widget) => {
const { w = 3, h = 2 } = placement(widget)
const width = Math.min(columns, Math.max(1, w))
if (x + width > columns) {
x = 0
y += shelf
shelf = 0
}
const item = { i: widget.id, x, y, w: width, h: Math.max(1, h) }
x += width
shelf = Math.max(shelf, item.h)
return item
})
}
function layoutOf(widgets: WidgetDef[], columns: number): Layout {
if (!isPlaced(widgets)) return packed(widgets, columns)
return widgets.map((widget) => {
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
const width = Math.min(columns, Math.max(1, w))
return {
i: widget.id,
x: Math.min(columns - width, Math.max(0, x)),
y: Math.max(0, y),
w: width,
h: Math.max(1, h),
}
})
}
const same = (a: Layout, b: Layout) =>
a.length === b.length &&
a.every((item, index) => {
const other = b[index]
return (
other &&
item.i === other.i &&
item.x === other.x &&
item.y === other.y &&
item.w === other.w &&
item.h === other.h
)
})
/**
* A dashboard, viewed or edited, over the same dotted canvas the flows use.
*
* View mode never mounts the grid library: a wall panel that only displays
* should not pay for the code that lets someone drag things around.
*/
export function DashboardEditor({
dashboard,
pageId,
edit,
}: {
dashboard: DashboardDef_Output
pageId?: string
dashboard: Dashboard
edit: boolean
}) {
const [draft, setDraft] = useState<DashboardDef_Output>(dashboard)
const [draft, setDraft] = useState<Dashboard>(dashboard)
const [selected, setSelected] = useState<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [pageId, setPageId] = useState<string | undefined>(
() => pagesOf(dashboard)[0]?.id,
)
const navigate = useNavigate()
const queryClient = useQueryClient()
const save = useSaveDashboard(dashboard.name)
const { showErrorToast } = useCustomToast()
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -73,7 +173,7 @@ export function DashboardEditor({
version.current = dashboard.version
}, [dashboard.version])
const commit = (next: DashboardDef_Output) => {
const commit = (next: Dashboard) => {
setDraft(next)
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => {
@@ -90,14 +190,24 @@ export function DashboardEditor({
}, AUTOSAVE_MS)
}
const remove = useMutation({
mutationFn: () => DashboardsService.deleteDashboard({ name: draft.name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
navigate({ to: "/dashboards" })
},
onError: handleError.bind(showErrorToast),
})
const columns = columnsOf(draft)
const pages = pagesOf(draft)
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
if (!page) return null
const section = sectionsOf(page)[0]
if (!section) return null
const widgets = widgetsOf(section)
const section = page ? sectionsOf(page)[0] : undefined
const widgets = section ? widgetsOf(section) : []
const { width, containerRef, mounted } = useContainerWidth()
const updateWidgets = (next: WidgetDef[]) =>
const updateWidgets = (next: WidgetDef[]) => {
if (!page || !section) return
commit({
...draft,
pages: pagesOf(draft).map((candidate) =>
@@ -113,19 +223,25 @@ export function DashboardEditor({
},
),
})
}
const addWidget = (type: WidgetKind) => {
const id = nextId(draft, type)
const bottom = layoutOf(widgets, columns).reduce(
(lowest, item) => Math.max(lowest, item.y + item.h),
0,
)
updateWidgets([
...widgets,
{
id,
type,
title: WIDGET_LABELS[type],
layout: { lg: { x: 0, y: 0, ...WIDGET_SIZES[type] } },
config: {},
layout: { lg: { x: 0, y: bottom, ...WIDGET_SIZES[type] } },
config: type === "chart" ? { series: [{}] } : {},
},
])
setSettingsOpen(false)
setSelected(id)
}
@@ -136,238 +252,276 @@ export function DashboardEditor({
),
)
const resize = (widget: WidgetDef, by: number) => {
const layout = (widget.layout ?? {}) as Record<
string,
{ x?: number; y?: number; w?: number; h?: number }
>
const current = layout.lg ?? { x: 0, y: 0, w: 3, h: 2 }
patch(widget.id, {
layout: {
...layout,
lg: { ...current, w: Math.min(12, Math.max(2, (current.w ?? 3) + by)) },
},
const layout = layoutOf(widgets, columns)
/** Store what the grid ended up doing, unless it did nothing. */
const applyLayout = (next: Layout) => {
if (same(layout, next)) return
const byId = new Map(next.map((item) => [item.i, item]))
updateWidgets(
widgets.map((widget) => {
const item = byId.get(widget.id)
if (!item) return widget
const placed: Placement = {
x: item.x,
y: item.y,
w: item.w,
h: item.h,
}
return {
...widget,
layout: { ...(widget.layout ?? {}), lg: placed },
}
}),
)
}
const active = widgets.find((widget) => widget.id === selected) ?? null
const panelOpen = Boolean(active) || settingsOpen
const setEdit = (next: boolean) => {
setSelected(null)
setSettingsOpen(false)
navigate({
to: "/dashboards/$name",
params: { name: draft.name },
search: next ? { edit: true } : {},
})
}
const active = widgets.find((widget) => widget.id === selected)
return (
<div className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Add</span>
{KINDS.map((kind) => (
<Button
key={kind}
variant="ghost"
size="sm"
className="h-8 text-xs"
data-testid={`add-widget-${kind}`}
onClick={() => addWidget(kind)}
>
<Plus />
{WIDGET_LABELS[kind]}
</Button>
))}
</div>
<SectionGrid
section={section}
dashboard={draft.name}
renderWidget={(widget) => (
<WidgetFrame
title={widget.title}
className={
widget.id === selected ? "ring-2 ring-primary" : undefined
}
actions={
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Narrower"
onClick={() => resize(widget, -1)}
>
<ChevronLeft />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Wider"
onClick={() => resize(widget, 1)}
>
<ChevronRight />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove widget"
onClick={() =>
updateWidgets(
widgets.filter((other) => other.id !== widget.id),
)
const body = !page ? (
<p className="text-sm text-muted-foreground">
This dashboard has no pages yet.
</p>
) : !edit ? (
<DashboardView dashboard={draft} pageId={page.id} />
) : widgets.length === 0 ? (
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
Nothing on this page yet. Add a widget from the bar below.
</p>
) : (
<div ref={containerRef}>
{/* Measured first: laying out against a guessed width would place every
widget once and then move it. */}
{mounted ? (
<GridLayout
width={width}
layout={layout}
onLayoutChange={applyLayout}
gridConfig={{
cols: columns,
rowHeight: ROW_HEIGHT,
margin: [GRID_GAP, GRID_GAP],
containerPadding: [0, 0],
}}
// Only the header moves a widget, so a slider under the cursor still
// slides and a switch still flips while the dashboard is being edited.
dragConfig={{ handle: ".widget-grip" }}
resizeConfig={{ handles: ["e", "s", "se"] }}
>
{widgets.map((widget) => (
<div key={widget.id}>
<WidgetFrame
title={widget.title}
issue={widgetIssue(widget)}
className={cn(
"cursor-pointer",
widget.id === selected && "ring-2 ring-primary",
)}
grip
onClick={(event) => {
if (!(event.target as Element).closest(INTERACTIVE)) {
setSettingsOpen(false)
setSelected(widget.id)
}
>
<X />
</Button>
</div>
}
>
<button
type="button"
className="min-h-0 flex-1 text-left"
onClick={() => setSelected(widget.id)}
>
<WidgetBody widget={widget} dashboard={draft.name} />
</button>
</WidgetFrame>
)}
/>
{active ? (
<WidgetSettings
widget={active}
onChange={(changes) => patch(active.id, changes)}
/>
) : (
<p className="text-sm text-muted-foreground">
Add a widget, or pick one to configure it.
</p>
)}
}}
>
<WidgetBody widget={widget} dashboard={draft.name} />
</WidgetFrame>
</div>
))}
</GridLayout>
) : null}
</div>
)
}
/** What one widget shows or does. Fields differ per type; the set is small. */
function WidgetSettings({
widget,
onChange,
}: {
widget: WidgetDef
onChange: (changes: Partial<WidgetDef>) => void
}) {
const { data: catalog } = useQuery(messageCatalogQueryOptions())
const messages = catalog?.data ?? []
const config = (widget.config ?? {}) as Record<string, unknown>
const isInput = INPUT_WIDGETS.has(widget.type)
const numericOnly = widget.type === "gauge" || widget.type === "chart"
const set = (key: string, value: unknown) =>
onChange({ config: { ...config, [key]: value } })
const choices = messages.filter((message) =>
numericOnly ? message.numeric : true,
)
return (
<div
className="grid gap-3 rounded-lg border border-border bg-card p-4 shadow-e1"
data-testid="widget-settings"
>
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{WIDGET_LABELS[widget.type]} settings
</span>
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Title</Label>
<Input
value={widget.title ?? ""}
onChange={(event) => onChange({ title: event.target.value })}
/>
<>
<div
className={cn(
"dot-canvas absolute inset-0 overflow-y-auto px-4 pb-24 pt-20 transition-[padding] duration-200",
panelOpen && "md:pr-[27rem]",
)}
data-testid="dashboard-canvas"
>
{body}
</div>
{widget.type === "markdown" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Text</Label>
<Input
value={String(config.content ?? "")}
placeholder="# Heading"
onChange={(event) => set("content", event.target.value)}
/>
</div>
) : (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">
{isInput ? "Publishes to" : "Shows"}
</Label>
<Select
value={String(config[isInput ? "target" : "message"] ?? "")}
onValueChange={(value) =>
set(isInput ? "target" : "message", value)
}
<div
className={cn(
"pointer-events-none absolute inset-0 transition-[right] duration-200",
panelOpen && "md:right-[27rem]",
)}
>
<CanvasTitle>
<span className="truncate px-3 py-1.5 text-sm font-medium">
{draft.title || draft.name}
</span>
{pages.length > 1 ? (
<Tabs value={page?.id} onValueChange={setPageId}>
<TabsList>
{pages.map((candidate) => (
<TabsTrigger key={candidate.id} value={candidate.id}>
{candidate.title || candidate.id}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
</CanvasTitle>
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
>
{edit ? (
<>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
aria-label="Add widget"
data-testid="add-widget"
>
<Plus />
</Button>
</PopoverTrigger>
<PopoverContent align="center" className="w-56 p-2">
<p className="px-2 py-1.5 text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
Add a widget
</p>
<div className="mt-1 grid gap-0.5">
{KINDS.map((kind) => (
<button
key={kind}
type="button"
className="rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50"
data-testid={`add-widget-${kind}`}
onClick={() => addWidget(kind)}
>
{WIDGET_LABELS[kind]}
</button>
))}
</div>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={() => {
setSelected(null)
setSettingsOpen(true)
}}
aria-label="Dashboard settings"
data-testid="edit-dashboard"
>
<Settings2 />
</Button>
</TooltipTrigger>
<TooltipContent>Dashboard settings</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
{save.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
</span>
</TooltipTrigger>
<TooltipContent>
{save.isPending ? "Saving" : "All changes saved"}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 !h-5" />
</>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
aria-label="Open the panel view"
data-testid="open-view"
asChild
>
<a
href={`/view/${draft.name}`}
target="_blank"
rel="noreferrer"
>
<ExternalLink />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Open what a wall panel sees</TooltipContent>
</Tooltip>
<Button
variant={edit ? "brand" : "secondary"}
size="sm"
className="h-11 gap-1.5 md:h-8"
onClick={() => setEdit(!edit)}
data-testid="toggle-edit"
>
<SelectTrigger data-testid="widget-message">
<SelectValue placeholder="Pick a message" />
</SelectTrigger>
<SelectContent>
{choices.map((message) => (
<SelectItem key={message.name} value={message.name}>
{message.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{edit ? (
<Check className="size-4" />
) : (
<Pencil className="size-4" />
)}
{edit ? "Done" : "Edit"}
</Button>
</motion.div>
</div>
{(widget.type === "stat" || widget.type === "gauge") && (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Unit</Label>
<Input
value={String(config.unit ?? "")}
placeholder="°C"
onChange={(event) => set("unit", event.target.value)}
/>
</div>
)}
{(widget.type === "gauge" || widget.type === "slider") && (
<div className="flex gap-2">
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Minimum</Label>
<Input
type="number"
value={String(config.min ?? 0)}
onChange={(event) => set("min", Number(event.target.value) || 0)}
/>
</div>
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Maximum</Label>
<Input
type="number"
value={String(config.max ?? 100)}
onChange={(event) => set("max", Number(event.target.value) || 0)}
/>
</div>
</div>
)}
{widget.type === "button" && (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Sends</Label>
<Input
value={String(config.value ?? "")}
placeholder="true"
onChange={(event) => {
const raw = event.target.value
const asNumber = Number(raw)
set(
"value",
raw === "true" || raw === "false"
? raw === "true"
: raw !== "" && Number.isFinite(asNumber)
? asNumber
: raw,
)
{edit ? (
<>
<WidgetPanel
widget={active}
onChange={(changes) => active && patch(active.id, changes)}
onDelete={() => {
if (!active) return
updateWidgets(widgets.filter((other) => other.id !== active.id))
setSelected(null)
}}
onClose={() => setSelected(null)}
/>
</div>
)}
</div>
<DashboardPanel
open={settingsOpen}
dashboard={draft}
widgetCount={widgets.length}
onChange={(changes) => commit({ ...draft, ...changes })}
onDelete={() => remove.mutate()}
onClose={() => setSettingsOpen(false)}
/>
</>
) : null}
</>
)
}
export { widgetStyle }
@@ -1,20 +1,33 @@
import type {
DashboardDef_Output,
PageDef_Output,
Placement,
SectionDef_Output,
WidgetDef,
} from "@/client"
import { cn } from "@/lib/utils"
import { WidgetBody, WidgetFrame } from "./widgets"
import "./dashboard.css"
import { WidgetBody, WidgetFrame, widgetIssue } from "./widgets"
/**
* Columns per breakpoint. A wall panel gets the full twelve, a phone gets
* three, which is what makes a stat tile still readable at arm's length.
* A dashboard, plus the settings the generated client does not carry yet.
*
* `columns` is stored by the backend but the SDK is regenerated on its own
* schedule, so it is widened here rather than waited for.
*/
const GRID = "grid-cols-3 md:grid-cols-6 lg:grid-cols-12"
export type Dashboard = DashboardDef_Output & { columns?: number }
/** One grid row, in pixels. Widget heights are multiples of this. */
const ROW = "5rem"
/** How many columns a wall panel is cut into, if the document does not say. */
export const DEFAULT_COLUMNS = 12
/** What a grid size setting may be set to; a panel is matched to one of these. */
export const COLUMN_CHOICES = [6, 8, 12, 16, 24]
/** One grid row, in pixels — the unit widget heights are multiples of. */
export const ROW_HEIGHT = 80
/** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */
export const GRID_GAP = 12
/**
* The generated client marks every list optional, because the server fills
@@ -24,40 +37,61 @@ export const pagesOf = (dashboard: DashboardDef_Output) => dashboard.pages ?? []
export const sectionsOf = (page: PageDef_Output) => page.sections ?? []
export const widgetsOf = (section: SectionDef_Output) => section.widgets ?? []
function placement(widget: WidgetDef) {
const layout = (widget.layout ?? {}) as Record<
string,
{ x?: number; y?: number; w?: number; h?: number }
>
export const columnsOf = (dashboard: Dashboard) =>
dashboard.columns || DEFAULT_COLUMNS
export function placement(widget: WidgetDef): Placement {
const layout = (widget.layout ?? {}) as Record<string, Placement>
return layout.lg ?? layout.md ?? layout.sm ?? {}
}
/**
* A widget's box, as plain CSS grid.
*
* View mode never loads a grid library: a wall panel that only displays
* should not pay for the code that lets someone drag things around.
* View mode never loads a grid library: a wall panel that only displays should
* not pay for the code that lets someone drag things around.
*/
export function widgetStyle(widget: WidgetDef): React.CSSProperties {
const { w = 3, h = 2 } = placement(widget)
export function widgetStyle(
widget: WidgetDef,
columns = DEFAULT_COLUMNS,
): React.CSSProperties {
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
const width = Math.min(columns, Math.max(1, w))
return {
gridColumn: `span ${Math.min(12, Math.max(1, w))}`,
gridRow: `span ${Math.max(1, h)}`,
}
"--x": Math.min(columns - width, Math.max(0, x)) + 1,
"--y": Math.max(0, y) + 1,
"--w": width,
"--h": Math.max(1, h),
} as React.CSSProperties
}
/**
* Has anyone actually arranged this dashboard?
*
* Before drag-and-drop every widget was written at 0,0, so honouring the
* stored position would pile the whole page onto one cell.
*/
export const isPlaced = (widgets: WidgetDef[]) =>
widgets.some((widget) => {
const { x = 0, y = 0 } = placement(widget)
return x > 0 || y > 0
})
export function SectionGrid({
section,
dashboard,
columns = DEFAULT_COLUMNS,
renderWidget,
className,
}: {
section: SectionDef_Output
/** Which dashboard this is, so an input widget can name itself. */
dashboard: string
columns?: number
renderWidget?: (widget: WidgetDef) => React.ReactNode
className?: string
}) {
const widgets = widgetsOf(section)
return (
<section className="grid gap-3">
{section.title ? (
@@ -66,15 +100,20 @@ export function SectionGrid({
</h2>
) : null}
<div
className={cn("grid gap-3", GRID, className)}
style={{ gridAutoRows: ROW }}
className={cn("widget-grid", className)}
data-placed={isPlaced(widgets) || undefined}
style={{ "--widget-cols": columns } as React.CSSProperties}
>
{widgetsOf(section).map((widget) => (
<div key={widget.id} style={widgetStyle(widget)} className="min-w-0">
{widgets.map((widget) => (
<div
key={widget.id}
style={widgetStyle(widget, columns)}
className="widget-cell"
>
{renderWidget ? (
renderWidget(widget)
) : (
<WidgetFrame title={widget.title}>
<WidgetFrame title={widget.title} issue={widgetIssue(widget)}>
<WidgetBody widget={widget} dashboard={dashboard} />
</WidgetFrame>
)}
@@ -90,7 +129,7 @@ export function DashboardView({
pageId,
renderWidget,
}: {
dashboard: DashboardDef_Output
dashboard: Dashboard
pageId?: string
renderWidget?: (widget: WidgetDef) => React.ReactNode
}) {
@@ -126,6 +165,7 @@ export function DashboardView({
key={section.id}
section={section}
dashboard={dashboard.name}
columns={columnsOf(dashboard)}
renderWidget={renderWidget}
/>
))}
@@ -0,0 +1,102 @@
/*
* Dashboard surface, routed through the design tokens.
*
* Scoped like `Flow/flow.css`: the grid maths and the react-grid-layout
* overrides live beside the components that use them rather than in index.css,
* so nothing here touches the byte-identical token blocks.
*/
/* The same dot grid the flow viewport paints, as plain CSS: a dashboard has no
zoom, so it needs none of React Flow's rescaling. */
.dot-canvas {
background-color: var(--background);
background-image: radial-gradient(
circle at 1px 1px,
color-mix(in srgb, var(--muted-foreground) 30%, transparent) 1.5px,
transparent 0
);
background-size: 24px 24px;
}
/*
* View mode's grid. Column count is per dashboard (`--widget-cols`), so a wall
* panel can be matched to its own width. Below the large breakpoint the stored
* placement is meaningless — three columns cannot hold a twelve-column
* arrangement — so widgets stack full width and keep only their height.
*/
.widget-grid {
display: grid;
gap: 0.75rem;
grid-auto-rows: 5rem;
grid-template-columns: minmax(0, 1fr);
}
.widget-cell {
grid-row: span var(--h);
min-width: 0;
}
@media (min-width: 64rem) {
.widget-grid {
grid-template-columns: repeat(var(--widget-cols), minmax(0, 1fr));
}
.widget-cell {
grid-column: span var(--w);
}
/* Only once something has actually been placed; an untouched dashboard has
every widget at 0,0 and would pile up on one cell. */
.widget-grid[data-placed] .widget-cell {
grid-column: var(--x) / span var(--w);
grid-row: var(--y) / span var(--h);
}
}
/*
* uPlot, routed through the tokens. Its own legend is the hover readout as
* well — the value each line carried at the cursor — so it is styled as chart
* furniture rather than replaced.
*/
.u-legend {
font-size: 0.75rem;
color: var(--muted-foreground);
margin-top: 0.25rem;
}
.u-legend .u-marker {
width: 0.5rem;
height: 0.5rem;
border-width: 2px;
border-radius: 9999px;
}
.u-legend .u-value {
font-variant-numeric: tabular-nums;
color: var(--foreground);
}
.u-legend .u-series.u-off {
opacity: 0.4;
}
.u-cursor-x,
.u-cursor-y {
border-color: color-mix(in srgb, var(--muted-foreground) 55%, transparent);
}
.u-select {
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
/* react-grid-layout ships a red placeholder and a black handle glyph. */
.react-grid-item.react-grid-placeholder {
background: var(--primary);
border-radius: var(--radius-lg);
opacity: 0.12;
}
.react-grid-item > .react-resizable-handle::after {
border-right-color: var(--muted-foreground);
border-bottom-color: var(--muted-foreground);
}
@@ -0,0 +1,436 @@
import { useQuery } from "@tanstack/react-query"
import { Plus, X } from "lucide-react"
import { useState } from "react"
import type { MessageInfo, WidgetDef } from "@/client"
import {
PANEL_SECTION,
PanelTitle,
SidePanel,
} from "@/components/Flow/SidePanel"
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { MAX_SERIES } from "./ChartWidget"
import { COLUMN_CHOICES, columnsOf, type Dashboard } from "./DashboardView"
import { messageCatalogQueryOptions } from "./queries"
import {
acceptsDtype,
INPUT_WIDGETS,
type Series,
seriesOf,
WIDGET_LABELS,
type WidgetKind,
widgetIssue,
} from "./widgets"
const config = (widget: WidgetDef) =>
(widget.config ?? {}) as Record<string, unknown>
const str = (value: unknown) => (value == null ? "" : String(value))
/** Which messages this kind of widget may be pointed at. */
function choicesFor(kind: WidgetKind, catalog: MessageInfo[]): MessageInfo[] {
const input = INPUT_WIDGETS.has(kind)
return catalog.filter(
(message) =>
acceptsDtype(kind, message.dtype) &&
// Flows own the namespace; a control can only set what one declares.
(!input || message.writable !== false),
)
}
/** The picker, which records the payload type it bound along with the name. */
function MessagePicker({
kind,
value,
label,
testId,
onPick,
}: {
kind: WidgetKind
value: string
label: string
testId?: string
onPick: (message: string, dtype: string) => void
}) {
const { data } = useQuery(messageCatalogQueryOptions())
const choices = choicesFor(kind, data?.data ?? [])
return (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">{label}</Label>
<Select
value={value}
onValueChange={(next) =>
onPick(
next,
choices.find((message) => message.name === next)?.dtype ?? "",
)
}
>
<SelectTrigger data-testid={testId}>
<SelectValue placeholder="Pick a message" />
</SelectTrigger>
<SelectContent>
{choices.map((message) => (
<SelectItem key={message.name} value={message.name}>
{message.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
/**
* What one widget shows or does.
*
* The same floating panel the flow editor uses for a node, so the two editors
* read as one surface.
*/
export function WidgetPanel({
widget,
onChange,
onDelete,
onClose,
}: {
widget: WidgetDef | null
onChange: (changes: Partial<WidgetDef>) => void
onDelete: () => void
onClose: () => void
}) {
if (!widget) return null
const cfg = config(widget)
const isInput = INPUT_WIDGETS.has(widget.type)
const issue = widgetIssue(widget)
const set = (changes: Record<string, unknown>) =>
onChange({ config: { ...cfg, ...changes } })
const series = seriesOf(widget)
const setSeries = (next: Series[]) => set({ series: next })
return (
<SidePanel
open={Boolean(widget)}
label="Widget settings"
testId="widget-panel"
bodyKey={widget.id}
onClose={onClose}
header={
<PanelTitle
value={widget.title ?? ""}
placeholder={WIDGET_LABELS[widget.type]}
label="Widget title"
onConfirm={(title) => onChange({ title })}
/>
}
footer={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onDelete}
data-testid="delete-widget"
>
Remove widget
</Button>
}
>
<div className="grid gap-5 p-4" data-testid="widget-settings">
<div className="grid gap-2">
<span className={PANEL_SECTION}>{WIDGET_LABELS[widget.type]}</span>
{issue ? (
<p className="text-sm text-destructive" data-testid="widget-error">
{issue}
</p>
) : null}
{widget.type === "markdown" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Text</Label>
<Input
value={str(cfg.content)}
placeholder="# Heading"
onChange={(event) => set({ content: event.target.value })}
/>
</div>
) : widget.type === "chart" ? (
<div className="grid gap-2">
{series.map((entry, index) => (
<div
// Position is the only identity a series row has.
key={`series-${index}`}
className="flex items-end gap-1.5"
>
<div className="min-w-0 flex-1">
<MessagePicker
kind="chart"
value={entry.message ?? ""}
label={index === 0 ? "Draws" : ""}
testId={index === 0 ? "widget-message" : undefined}
onPick={(message, dtype) =>
setSeries(
series.map((other, at) =>
at === index ? { ...other, message, dtype } : other,
),
)
}
/>
</div>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove series"
onClick={() =>
setSeries(series.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
))}
{series.length < MAX_SERIES ? (
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setSeries([...series, {}])}
data-testid="add-series"
>
<Plus />
Add series
</Button>
) : null}
</div>
) : (
<MessagePicker
kind={widget.type}
value={str(cfg[isInput ? "target" : "message"])}
label={isInput ? "Publishes to" : "Shows"}
testId="widget-message"
onPick={(message, dtype) =>
set({ [isInput ? "target" : "message"]: message, dtype })
}
/>
)}
</div>
{widget.type === "chart" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Points kept</Label>
<Input
type="number"
value={str(
(cfg.history as { points?: number } | undefined)?.points ?? 300,
)}
onChange={(event) =>
set({ history: { points: Number(event.target.value) || 0 } })
}
/>
<p className="text-xs text-muted-foreground">
How much past the engine keeps for these messages.
</p>
</div>
) : null}
{widget.type === "stat" || widget.type === "gauge" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Unit</Label>
<Input
value={str(cfg.unit)}
placeholder="°C"
onChange={(event) => set({ unit: event.target.value })}
/>
</div>
) : null}
{widget.type === "gauge" || widget.type === "slider" ? (
<div className="flex gap-2">
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Minimum</Label>
<Input
type="number"
value={str(cfg.min ?? 0)}
onChange={(event) =>
set({ min: Number(event.target.value) || 0 })
}
/>
</div>
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Maximum</Label>
<Input
type="number"
value={str(cfg.max ?? 100)}
onChange={(event) =>
set({ max: Number(event.target.value) || 0 })
}
/>
</div>
</div>
) : null}
{widget.type === "button" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Sends</Label>
<Input
value={str(cfg.value)}
placeholder="true"
onChange={(event) => {
const raw = event.target.value
const asNumber = Number(raw)
set({
value:
raw === "true" || raw === "false"
? raw === "true"
: raw !== "" && Number.isFinite(asNumber)
? asNumber
: raw,
})
}}
/>
</div>
) : null}
</div>
</SidePanel>
)
}
/**
* The dashboard's own settings, in the panel its widgets use.
*
* The grid size is the one that matters: a wall panel is a fixed width, and
* twelve columns on a seven-inch screen is a different dashboard than twelve
* on a television.
*/
export function DashboardPanel({
open,
dashboard,
widgetCount,
onChange,
onDelete,
onClose,
}: {
open: boolean
dashboard: Dashboard
widgetCount: number
onChange: (changes: Partial<Dashboard>) => void
onDelete: () => void
onClose: () => void
}) {
const [confirmOpen, setConfirmOpen] = useState(false)
return (
<>
<SidePanel
open={open}
label="Dashboard settings"
testId="dashboard-panel"
bodyKey={dashboard.name}
onClose={onClose}
header={
<PanelTitle
value={dashboard.title ?? ""}
placeholder={dashboard.name}
label="Dashboard title"
onConfirm={(title) => onChange({ title })}
/>
}
footer={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setConfirmOpen(true)}
data-testid="delete-dashboard"
>
Delete dashboard
</Button>
}
>
<div className="grid gap-5 p-4">
<div className="grid gap-2">
<span className={PANEL_SECTION}>Grid</span>
<Select
value={String(columnsOf(dashboard))}
onValueChange={(value) => onChange({ columns: Number(value) })}
>
<SelectTrigger data-testid="dashboard-columns">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLUMN_CHOICES.map((count) => (
<SelectItem key={count} value={String(count)}>
{count} columns
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
How many columns wide this dashboard is laid out, so it can be
matched to the panel it will hang on.
</p>
</div>
<div className="grid gap-2">
<span className={PANEL_SECTION}>Contents</span>
<p className="text-sm text-muted-foreground">
<span className="font-mono">{dashboard.name}</span> {" "}
{widgetCount === 0
? "nothing on it yet."
: `${widgetCount} widget${widgetCount === 1 ? "" : "s"}.`}
</p>
</div>
</div>
</SidePanel>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Delete {dashboard.title || dashboard.name}?
</DialogTitle>
<DialogDescription>
This removes the dashboard and its widgets. The flows it read from
are untouched, and its history stays in the flow store's git
repository.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
Keep it
</Button>
<Button
variant="destructive"
onClick={() => {
setConfirmOpen(false)
onDelete()
}}
data-testid="confirm-delete-dashboard"
>
Delete dashboard
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
+111 -3
View File
@@ -13,7 +13,13 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { ChartWidget } from "./ChartWidget"
import { usePublishMessage } from "./queries"
/** Widget types that put a value into the graph rather than read one. */
@@ -27,6 +33,27 @@ export const INPUT_WIDGETS = new Set([
export type WidgetKind = WidgetDef["type"]
/**
* What a widget can be pointed at, by payload type.
*
* A switch that reads a float has nothing to show and nothing safe to send, so
* the pairing is part of the document rather than a matter of taste. The same
* table is enforced on the server (`app/flow/dashboards.py`); a type missing
* from it takes anything.
*/
export const WIDGET_DTYPES: Partial<Record<WidgetKind, string[]>> = {
gauge: ["float", "int"],
chart: ["float", "int"],
slider: ["float", "int"],
switch: ["bool"],
}
/** Whether a message of this payload type may drive this kind of widget. */
export function acceptsDtype(kind: WidgetKind, dtype: string | undefined) {
const allowed = WIDGET_DTYPES[kind]
return !allowed || !dtype || allowed.includes(dtype)
}
export const WIDGET_LABELS: Record<WidgetKind, string> = {
stat: "Value",
gauge: "Gauge",
@@ -76,6 +103,51 @@ function format(value: unknown, precision: number | null): string {
return String(value)
}
/** One line of a chart, as the document stores it. */
export type Series = { message?: string; dtype?: string; label?: string }
export const seriesOf = (widget: WidgetDef): Series[] =>
(config(widget).series ?? []) as Series[]
/**
* What is wrong with this widget's wiring, if anything.
*
* Both halves are checked from the document alone — the picker records the
* payload type it bound — so a wall panel can flag a broken tile without
* fetching the message catalogue first.
*/
export function widgetIssue(widget: WidgetDef): string | null {
if (widget.type === "markdown") return null
const cfg = config(widget)
if (widget.type === "chart") {
const series = seriesOf(widget)
if (series.length === 0) return "This chart has no series yet."
const wrong = series.find(
(entry) => !entry.message || !acceptsDtype("chart", entry.dtype),
)
if (wrong) {
return wrong.message
? `${wrong.message} is a ${wrong.dtype}; a chart can only draw numbers.`
: "One of the series is not bound to a message."
}
return null
}
const input = INPUT_WIDGETS.has(widget.type)
const bound = text(cfg[input ? "target" : "message"])
if (!bound) {
return input
? "This control does not publish to a message yet."
: "This widget is not bound to a message yet."
}
const dtype = cfg.dtype === undefined ? undefined : text(cfg.dtype)
if (!acceptsDtype(widget.type, dtype)) {
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.`
}
return null
}
/**
* The frame every widget sits in.
*
@@ -86,22 +158,40 @@ export function WidgetFrame({
title,
children,
actions,
issue,
grip,
className,
onClick,
}: {
title?: string
children: React.ReactNode
actions?: React.ReactNode
/** Mis-wired: the same red dot and tooltip a failing node carries. */
issue?: string | null
/** Make the header the handle the editor drags the widget by. */
grip?: boolean
className?: string
onClick?: React.MouseEventHandler<HTMLDivElement>
}) {
return (
// A card is not a control: the click only picks it in edit mode, and every
// interactive element inside keeps its own role and keyboard handling.
// biome-ignore lint/a11y/useKeyWithClickEvents: see above.
// biome-ignore lint/a11y/noStaticElementInteractions: see above.
<div
className={cn(
"flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1",
className,
)}
onClick={onClick}
>
{title || actions ? (
<div className="flex items-start justify-between gap-2">
{title || actions || issue || grip ? (
<div
className={cn(
"flex items-start justify-between gap-2",
grip && "widget-grip -m-1 cursor-grab p-1 active:cursor-grabbing",
)}
>
{title ? (
<span className="truncate text-sm text-muted-foreground">
{title}
@@ -109,7 +199,24 @@ export function WidgetFrame({
) : (
<span />
)}
{actions}
<span className="flex shrink-0 items-center gap-1.5">
{issue ? (
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
className="size-2 shrink-0 rounded-full bg-destructive"
aria-label={issue}
data-testid="widget-issue"
/>
</TooltipTrigger>
<TooltipContent className="max-w-xs break-words">
{issue}
</TooltipContent>
</Tooltip>
) : null}
{actions}
</span>
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col justify-center">
@@ -434,6 +541,7 @@ const RENDERERS: Partial<
> = {
stat: StatWidget,
gauge: GaugeWidget,
chart: ChartWidget,
markdown: MarkdownWidget,
button: ButtonWidget,
switch: SwitchWidget,