Files
app/frontend/src/components/Dashboard/widgets.tsx
T
stroblme 42244196a1 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
2026-08-16 17:13:10 +02:00

564 lines
17 KiB
TypeScript

import { useState } from "react"
import type { WidgetDef } from "@/client"
import { useLiveValue } from "@/components/Flow/liveStore"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
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. */
export const INPUT_WIDGETS = new Set([
"button",
"switch",
"slider",
"input",
"dropdown",
])
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",
chart: "Chart",
markdown: "Text",
button: "Button",
switch: "Switch",
slider: "Slider",
input: "Input",
dropdown: "Dropdown",
}
/** Default footprint per type, in grid units. */
export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
stat: { w: 3, h: 2 },
gauge: { w: 3, h: 3 },
chart: { w: 6, h: 4 },
markdown: { w: 6, h: 2 },
button: { w: 3, h: 2 },
switch: { w: 3, h: 2 },
slider: { w: 4, h: 2 },
input: { w: 4, h: 2 },
dropdown: { w: 4, h: 2 },
}
function config(widget: WidgetDef): Record<string, unknown> {
return (widget.config ?? {}) as Record<string, unknown>
}
function text(value: unknown, fallback = ""): string {
return value === null || value === undefined ? fallback : String(value)
}
function num(value: unknown, fallback: number): number {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
/** Formats a reading the way a panel across the room should read it. */
function format(value: unknown, precision: number | null): string {
if (value === null || value === undefined) return "—"
if (typeof value === "boolean") return value ? "On" : "Off"
if (typeof value === "number") {
return precision === null ? String(value) : value.toFixed(precision)
}
if (typeof value === "object") return JSON.stringify(value)
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.
*
* A card rather than floating chrome: a dashboard is content, and the panels
* that float are the ones that sit over something.
*/
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 || 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}
</span>
) : (
<span />
)}
<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">
{children}
</div>
</div>
)
}
function Unbound() {
return <p className="text-sm text-muted-foreground">Pick a message.</p>
}
// ---------------------------------------------------------------------------
// Display
// ---------------------------------------------------------------------------
function StatWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
if (!message) return <Unbound />
const precision = cfg.precision === undefined ? null : num(cfg.precision, 1)
return (
<div className="flex items-baseline gap-1.5">
<span className="truncate text-3xl tabular-nums">
{format(live?.value, precision)}
</span>
{cfg.unit ? (
<span className="text-base text-muted-foreground">
{text(cfg.unit)}
</span>
) : null}
</div>
)
}
/**
* A dial, drawn as an arc.
*
* The number is always written out as well: a reading that only exists as an
* angle is unreadable to anyone who cannot judge one.
*/
function GaugeWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
if (!message) return <Unbound />
const min = num(cfg.min, 0)
const max = num(cfg.max, 100)
const value = typeof live?.value === "number" ? live.value : null
const fraction =
value === null
? 0
: Math.min(1, Math.max(0, (value - min) / (max - min || 1)))
// A 240° arc, the shape a dial is expected to have.
const radius = 42
const sweep = 240
const start = 150
const point = (angle: number) => {
const radians = (angle * Math.PI) / 180
return [50 + radius * Math.cos(radians), 50 + radius * Math.sin(radians)]
}
const arc = (from: number, to: number) => {
const [x1, y1] = point(from)
const [x2, y2] = point(to)
const large = Math.abs(to - from) > 180 ? 1 : 0
return `M ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2}`
}
return (
<div className="flex min-h-0 flex-1 items-center justify-center">
<svg
viewBox="0 0 100 78"
className="h-full max-h-full w-full"
role="img"
aria-label={`${format(value, 1)} of ${max}`}
>
<path
d={arc(start, start + sweep)}
fill="none"
stroke="var(--muted)"
strokeWidth={9}
strokeLinecap="round"
/>
{fraction > 0 ? (
<path
d={arc(start, start + sweep * fraction)}
fill="none"
stroke="var(--primary)"
strokeWidth={9}
strokeLinecap="round"
/>
) : null}
<text
x={50}
y={54}
textAnchor="middle"
className="fill-foreground text-[13px] tabular-nums"
>
{format(
value,
cfg.precision === undefined ? 1 : num(cfg.precision, 1),
)}
{cfg.unit ? text(cfg.unit) : ""}
</text>
</svg>
</div>
)
}
/**
* A very small markdown subset: headings, bold, code, links, list items.
*
* Enough for the labels and notes a dashboard carries, and not worth a parser.
*/
function MarkdownWidget({ widget }: WidgetProps) {
const content = text(config(widget).content)
const lines = content.split("\n")
return (
<div className="grid gap-1 text-sm">
{lines.map((line, index) => {
const heading = /^(#{1,3})\s+(.*)$/.exec(line)
const body = heading ? heading[2] : line.replace(/^[-*]\s+/, "")
const bullet = !heading && /^[-*]\s+/.test(line)
return (
<p
// Plain text: position is the only identity a line has.
key={`line-${index}`}
className={cn(
heading?.[1] === "#" && "text-lg font-medium",
heading?.[1] === "##" && "font-medium",
heading?.[1] === "###" && "text-muted-foreground",
bullet && "pl-4",
)}
>
{bullet ? "• " : ""}
{body}
</p>
)
})}
</div>
)
}
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
/** Publishing, with the value shown as sent until the engine confirms it. */
function usePublish(widget: WidgetDef, dashboard: string) {
const cfg = config(widget)
const target = text(cfg.target)
const publish = usePublishMessage()
const live = useLiveValue(target || undefined)
return {
target,
live,
send: (value: unknown) => {
if (!target) return
publish.mutate({
name: target,
value,
dashboard,
widget: widget.id,
label: widget.title || widget.id,
kind: widget.type,
})
},
pending: publish.isPending,
}
}
function ButtonWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, send, pending } = usePublish(widget, dashboard)
if (!target) return <Unbound />
return (
<Button
variant="secondary"
className="w-full"
disabled={pending}
onClick={() => send(cfg.value ?? true)}
>
{text(cfg.label, widget.title || "Send")}
</Button>
)
}
function SwitchWidget({ widget, dashboard }: WidgetProps) {
const { target, live, send } = usePublish(widget, dashboard)
if (!target) return <Unbound />
return (
<div className="flex items-center justify-between gap-2">
<span className="text-sm">{live?.value === true ? "On" : "Off"}</span>
<Switch
checked={live?.value === true}
aria-label={widget.title || target}
onCheckedChange={(checked) => send(checked)}
/>
</div>
)
}
function SliderWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, live, send } = usePublish(widget, dashboard)
const min = num(cfg.min, 0)
const max = num(cfg.max, 100)
const step = num(cfg.step, 1)
// While dragging, the handle follows the finger rather than the engine.
const [dragging, setDragging] = useState<number | null>(null)
if (!target) return <Unbound />
const current =
dragging ?? (typeof live?.value === "number" ? live.value : min)
return (
<div className="grid gap-2">
<div className="flex items-baseline justify-between">
<span className="text-2xl tabular-nums">{current}</span>
{cfg.unit ? (
<span className="text-sm text-muted-foreground">
{text(cfg.unit)}
</span>
) : null}
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={current}
aria-label={widget.title || target}
className="h-11 w-full accent-[var(--primary)] md:h-8"
onChange={(event) => setDragging(Number(event.target.value))}
// Only the release publishes: dragging would otherwise send a value
// per pixel and flood whatever is listening.
onPointerUp={() => {
if (dragging !== null) send(dragging)
setDragging(null)
}}
onKeyUp={() => {
if (dragging !== null) send(dragging)
setDragging(null)
}}
/>
</div>
)
}
function InputWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, live, send } = usePublish(widget, dashboard)
const [draft, setDraft] = useState<string | null>(null)
if (!target) return <Unbound />
const asNumber = cfg.dtype === "float" || cfg.dtype === "int"
const commit = () => {
if (draft === null) return
send(asNumber ? Number(draft) || 0 : draft)
setDraft(null)
}
return (
<Input
value={draft ?? text(live?.value)}
type={asNumber ? "number" : "text"}
aria-label={widget.title || target}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") commit()
}}
/>
)
}
function DropdownWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, live, send } = usePublish(widget, dashboard)
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
if (!target) return <Unbound />
return (
<div className="grid gap-1.5">
<Label className="sr-only">{widget.title || target}</Label>
<Select
value={text(live?.value)}
onValueChange={(value) => send(asOriginal(value, options))}
>
<SelectTrigger aria-label={widget.title || target}>
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={text(option.value)} value={text(option.value)}>
{option.label ?? text(option.value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
/** Radix hands back a string; the message wants whatever was configured. */
function asOriginal(selected: string, options: { value?: unknown }[]): unknown {
const match = options.find((option) => text(option.value) === selected)
return match ? match.value : selected
}
// ---------------------------------------------------------------------------
export type WidgetProps = { widget: WidgetDef; dashboard: string }
const RENDERERS: Partial<
Record<WidgetKind, (props: WidgetProps) => React.ReactNode>
> = {
stat: StatWidget,
gauge: GaugeWidget,
chart: ChartWidget,
markdown: MarkdownWidget,
button: ButtonWidget,
switch: SwitchWidget,
slider: SliderWidget,
input: InputWidget,
dropdown: DropdownWidget,
}
export function WidgetBody({ widget, dashboard }: WidgetProps) {
const Renderer = RENDERERS[widget.type]
if (!Renderer) {
return (
<p className="text-sm text-muted-foreground">
{WIDGET_LABELS[widget.type]} widgets are not drawn yet.
</p>
)
}
return <Renderer widget={widget} dashboard={dashboard} />
}