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
+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,