flow: structured dtypes, and the widgets that read them

A series, record or list message declares its shape instead of riding
DType.JSON, so a widget binds a shape rather than some JSON and a wrong
binding is refused before anything runs. A list declares its item type,
which is what keeps list[float] expressible for a pipeline.

On top of that: an agenda over a list, a notification over a record, and
a dashboard alert channel that publishes engine faults as one — so a
panel can show what went wrong without a flow wiring it by hand.

Also: only None means a node published nothing, a falsy value of the
wrong shape is now the named error it always should have been; and the
gauge's readout says its size is viewBox geometry rather than type scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 15:06:45 +02:00
co-authored by Claude Opus 5
parent 18837e8880
commit 413501c6ce
16 changed files with 751 additions and 43 deletions
+20 -7
View File
@@ -58,6 +58,8 @@ export function UplotChart({
labels,
plots,
empty = "Nothing has come through yet.",
unit,
yRange,
onCursor,
onSelect,
}: {
@@ -66,6 +68,10 @@ export function UplotChart({
/** The points of each series, in the same order as `labels`. */
plots: HistoryPoint[][]
empty?: string
/** Written after every reading, on the axis and in the legend. */
unit?: string
/** A y axis fixed to these bounds; unset lets it follow the data. */
yRange?: [number, number]
/** The x value under the pointer, and null once it leaves the plot. */
onCursor?: (ts: number | null) => void
/** The x value clicked, or null for a click that landed on no point. */
@@ -85,8 +91,9 @@ export function UplotChart({
const points = plots.reduce((total, plot) => total + plot.length, 0)
// The identity of the series set: the chart is rebuilt when it changes,
// while a new reading only sets its data.
const key = labels.join(" ")
// while a new reading only sets its data. The unit and the fixed range are
// part of it — both are baked into the axes when the chart is built.
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}`
// uPlot leaves its axes half-initialised while the scales have no range, and
// a resize in that window (a card still settling, say) draws them anyway and
// throws. Waiting for the first reading avoids the state altogether.
@@ -139,16 +146,21 @@ export function UplotChart({
},
],
},
scales: { x: { time: true } },
scales: {
x: { time: true },
...(yRange ? { y: { range: yRange } } : {}),
},
axes: [
{ ...axis, size: 28 },
{
...axis,
size: 46,
// The unit is written after every tick, so the gutter widens to
// hold it rather than clipping the number in front of it.
size: unit ? 62 : 46,
// The gutter has a fixed width, so a grouped "15,000" would be
// clipped to something that reads as a different number entirely.
values: (_self: uPlot, ticks: number[]) =>
ticks.map((value) => si(value)),
ticks.map((value) => (unit ? `${si(value)} ${unit}` : si(value))),
},
],
series: [
@@ -160,8 +172,9 @@ export function UplotChart({
// rebuilt chart.
stroke: () => seriesColor(index),
// The cursor readout is what decides how wide the legend gets, so
// it is shortened here and the unit named in the card's title.
value: (_self: uPlot, raw: number) => si(raw),
// it is shortened here; a named unit is short enough to keep.
value: (_self: uPlot, raw: number) =>
unit ? `${si(raw)} ${unit}` : si(raw),
// Series arrive on their own clocks; a joined table is mostly
// holes, and a line with a hole per point is not a line.
spanGaps: true,
+155 -1
View File
@@ -43,9 +43,13 @@ export type WidgetKind = WidgetDef["type"]
*/
export const WIDGET_DTYPES: Partial<Record<WidgetKind, string[]>> = {
gauge: ["float", "int"],
// A chart reading the engine's ring. One that queries binds a `series`
// answer and a `record` request instead, checked in `widgetIssue`.
chart: ["float", "int"],
slider: ["float", "int"],
switch: ["bool"],
agenda: ["list"],
notification: ["record"],
}
/** Whether a message of this payload type may drive this kind of widget. */
@@ -59,6 +63,8 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
gauge: "Gauge",
chart: "Chart",
markdown: "Text",
agenda: "Agenda",
notification: "Notification",
button: "Button",
switch: "Switch",
slider: "Slider",
@@ -72,6 +78,8 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
gauge: { w: 3, h: 3 },
chart: { w: 6, h: 4 },
markdown: { w: 6, h: 2 },
agenda: { w: 4, h: 4 },
notification: { w: 4, h: 2 },
button: { w: 3, h: 2 },
switch: { w: 3, h: 2 },
slider: { w: 4, h: 2 },
@@ -120,6 +128,20 @@ export function widgetIssue(widget: WidgetDef): string | null {
if (widget.type === "markdown") return null
const cfg = config(widget)
if (widget.type === "chart" && cfg.source === "query") {
if (!text(cfg.request)) return "This chart does not ask for anything yet."
if (!text(cfg.message)) return "This chart has no answer to draw yet."
const answer = text(cfg.dtype)
if (answer && answer !== "series") {
return `${text(cfg.message)} is a ${answer}; a chart that queries draws a series.`
}
const asked = text(cfg.request_dtype)
if (asked && asked !== "record") {
return `${text(cfg.request)} is a ${asked}; a request is a record.`
}
return null
}
if (widget.type === "chart") {
const series = seriesOf(widget)
if (series.length === 0) return "This chart has no series yet."
@@ -317,8 +339,11 @@ function GaugeWidget({ widget }: WidgetProps) {
<text
x={50}
y={54}
// User units of the viewBox, not the text scale: the readout has to
// stay proportional to the dial at whatever size the tile is.
fontSize={13}
textAnchor="middle"
className="fill-foreground text-[13px] tabular-nums"
className="fill-foreground tabular-nums"
>
{format(
value,
@@ -365,6 +390,119 @@ function MarkdownWidget({ widget }: WidgetProps) {
)
}
/** One item of an agenda, as the `list` message declares it. */
type AgendaItem = { title: string; ts: number; all_day?: boolean }
const DAY_MS = 86_400_000
/**
* Which day something falls on, said the way a person would.
*
* Today and tomorrow by name, the rest of the week by weekday, and anything
* further out by date — past a week "Thursday" stops telling you which one.
*/
function dayLabel(when: Date, now: Date): string {
const midnight = new Date(now).setHours(0, 0, 0, 0)
const days = Math.floor(
(new Date(when).setHours(0, 0, 0, 0) - midnight) / DAY_MS,
)
if (days === 0) return "Today"
if (days === 1) return "Tomorrow"
if (days < 7) return when.toLocaleDateString(undefined, { weekday: "long" })
return when.toLocaleDateString()
}
/**
* What is coming up, from a `list` of items the message declares.
*
* The shape is the widget's contract rather than a path per binding: every
* item is `{title, ts}` with an optional `all_day`, so a flow answering with
* a calendar decides what an entry is called and this only has to draw it.
*/
function AgendaWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
if (!message) return <Unbound />
const now = new Date()
const today = new Date(now).setHours(0, 0, 0, 0) / 1000
const items = (Array.isArray(live?.value) ? live.value : [])
.filter(
(item): item is AgendaItem =>
typeof item?.title === "string" && Number.isFinite(item?.ts),
)
.filter((item) => item.ts >= today)
.sort((a, b) => a.ts - b.ts)
.slice(0, num(cfg.count, 5))
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">Nothing coming up.</p>
}
return (
<ul className="grid gap-1.5 text-sm">
{items.map((item, index) => {
const when = new Date(item.ts * 1000)
return (
<li
// Two entries can share a title and a time; position is the identity.
key={`item-${index}`}
className="flex items-baseline gap-2"
>
<span className="shrink-0 text-muted-foreground tabular-nums">
{dayLabel(when, now)}
{item.all_day
? ""
: ` ${when.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}`}
</span>
<span className="truncate">{item.title}</span>
</li>
)
})}
</ul>
)
}
/**
* The last thing worth saying, held until something replaces it.
*
* No state of its own: the live store already keeps the latest value of a
* message, so what was published stays on the panel until the next one lands.
*/
function NotificationWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
if (!message) return <Unbound />
const record = (live?.value ?? null) as Record<string, unknown> | null
const title = text(record?.title)
const body = text(record?.body)
if (!title && !body) {
return <p className="text-sm text-muted-foreground">Nothing to report.</p>
}
return (
<div className="grid gap-1">
{title ? (
<p
className={cn(
"font-medium",
record?.severity === "error" && "text-destructive",
)}
>
{title}
</p>
) : null}
{body ? <p className="text-sm text-muted-foreground">{body}</p> : null}
</div>
)
}
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
@@ -437,6 +575,12 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
const current =
dragging ?? (typeof live?.value === "number" ? live.value : min)
// A 2022 °C setpoint at 0.1 is unusable without marks to aim at. Past
// fifty of them the ticks are a smear, so the browser gets none.
const steps = step > 0 ? (max - min) / step : 0
const ticks = Number.isFinite(steps) && steps > 0 && steps <= 50 ? steps : 0
const ticksId = `ticks-${widget.id}`
return (
<div className="grid gap-2">
<div className="flex items-baseline justify-between">
@@ -447,12 +591,20 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
</span>
) : null}
</div>
{ticks ? (
<datalist id={ticksId}>
{Array.from({ length: Math.floor(ticks) + 1 }, (_, index) => (
<option key={index} value={min + index * step} />
))}
</datalist>
) : null}
<input
type="range"
min={min}
max={max}
step={step}
value={current}
list={ticks ? ticksId : undefined}
aria-label={widget.title || target}
className="h-11 w-full accent-[var(--primary)] md:h-8"
onChange={(event) => setDragging(Number(event.target.value))}
@@ -543,6 +695,8 @@ const RENDERERS: Partial<
gauge: GaugeWidget,
chart: ChartWidget,
markdown: MarkdownWidget,
agenda: AgendaWidget,
notification: NotificationWidget,
button: ButtonWidget,
switch: SwitchWidget,
slider: SliderWidget,
+39 -1
View File
@@ -50,7 +50,19 @@ import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
const NodeEditor = lazy(() => import("./NodeEditor"))
const DTYPES: DType[] = ["float", "int", "str", "bool", "json"]
const DTYPES: DType[] = [
"float",
"int",
"str",
"bool",
"json",
"series",
"record",
"list",
]
/** What a list may hold. One declared level: no list of lists. */
const ITEM_DTYPES: DType[] = ["record", "float", "int", "str", "bool", "json"]
/** Radix selects cannot hold an empty value, so "no secret" needs a name. */
const NO_SECRET = "__none__"
@@ -274,6 +286,29 @@ function PortList({
))}
</SelectContent>
</Select>
{spec.dtype === "list" ? (
<Select
value={spec.item ?? "record"}
onValueChange={(value) =>
update(index, { item: value as DType })
}
>
<SelectTrigger
className="!h-8 w-[92px] text-sm"
aria-label="Item type"
title="What each item of the list is"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ITEM_DTYPES.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Input
type="number"
min={0}
@@ -765,6 +800,9 @@ const PLACEHOLDER: Record<DType, string> = {
bool: "False",
str: '""',
json: "{}",
series: '{"lines": []}',
record: "{}",
list: "[]",
}
const SCAFFOLD_DOC =