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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user