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
564 lines
17 KiB
TypeScript
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} />
|
|
}
|