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
+30 -4
View File
@@ -298,7 +298,7 @@ export const ChannelSchema = {
},
kind: {
type: 'string',
enum: ['ntfy', 'smtp', 'webhook'],
enum: ['ntfy', 'smtp', 'webhook', 'dashboard'],
title: 'Kind'
},
enabled: {
@@ -320,10 +320,15 @@ export const ChannelSchema = {
export const DTypeSchema = {
type: 'string',
enum: ['float', 'int', 'str', 'bool', 'json'],
enum: ['float', 'int', 'str', 'bool', 'json', 'series', 'record', 'list'],
title: 'DType',
description: `Serializable payload types.
The scalars carry what a single reading can say. The three structured ones
are declared shapes rather than "some JSON": a widget or a downstream node
knows what it is getting before anything runs, which is what lets the
dashboard picker offer a message and refuse a wrong binding.
Binary payloads (tensors, images) will arrive later as explicitly declared
codec fields; until then everything on the wire is JSON.`
} as const;
@@ -1151,6 +1156,16 @@ export const MessageSpecSchema = {
'$ref': '#/components/schemas/DType',
default: 'float'
},
item: {
anyOf: [
{
'$ref': '#/components/schemas/DType'
},
{
type: 'null'
}
]
},
interval: {
type: 'number',
minimum: 0,
@@ -1172,6 +1187,10 @@ export const MessageSpecSchema = {
:param port: The identifier the node function sees. Defaults to the last
segment of \`\`name\`\`, so unqualified flows read naturally.
:param dtype: Payload type, validated on every message that passes through.
:param item: The type of each item of a \`\`list\`\` port, ignored otherwise.
Unset means \`\`record\`\`, which is what the agenda and forecast widgets
read; \`\`float\`\` is the numeric list a pipeline passes around. A list of
lists, or of series, is refused — one declared level is the point.
:param interval: Deliver at most every this many seconds; 0 is every time.
On an output it holds back publishing, on an input it holds back waking
the node. The value is never lost — state keeps the latest — only the
@@ -2497,7 +2516,7 @@ export const WidgetDefSchema = {
},
type: {
type: 'string',
enum: ['stat', 'gauge', 'chart', 'markdown', 'button', 'switch', 'slider', 'input', 'dropdown'],
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'button', 'switch', 'slider', 'input', 'dropdown'],
title: 'Type'
},
title: {
@@ -2525,7 +2544,14 @@ export const WidgetDefSchema = {
\`\`config\`\` is per type — a chart names its series, a button names the
message it publishes — and is validated against the type below rather than
by a schema per class, because the whole set is small and closed.`
by a schema per class, because the whole set is small and closed.
A chart comes in two kinds. The default reads what the engine kept for a
message. One with \`\`source: "query"\`\` asks instead, and its config is
\`\`{source, request, request_dtype: "record", message, dtype: "series",
refresh_s, range_s}\`\`: it publishes \`\`{range_s, interval_s}\`\` to
\`\`request\`\` exactly as a slider publishes a value, and draws the \`\`series\`\`
a flow answers with on \`\`message\`\`.`
} as const;
export const app__api__routes__dashboards__PublishRequestSchema = {
+22 -5
View File
@@ -120,14 +120,14 @@ export type BrainNode = {
*/
export type Channel = {
name: string;
kind: 'ntfy' | 'smtp' | 'webhook';
kind: 'ntfy' | 'smtp' | 'webhook' | 'dashboard';
enabled?: boolean;
config?: {
[key: string]: unknown;
};
};
export type kind = 'ntfy' | 'smtp' | 'webhook';
export type kind = 'ntfy' | 'smtp' | 'webhook' | 'dashboard';
/**
* A dashboard as stored, and as the API hands it over.
@@ -185,10 +185,15 @@ export type DeadLetter = {
/**
* Serializable payload types.
*
* The scalars carry what a single reading can say. The three structured ones
* are declared shapes rather than "some JSON": a widget or a downstream node
* knows what it is getting before anything runs, which is what lets the
* dashboard picker offer a message and refuse a wrong binding.
*
* Binary payloads (tensors, images) will arrive later as explicitly declared
* codec fields; until then everything on the wire is JSON.
*/
export type DType = 'float' | 'int' | 'str' | 'bool' | 'json';
export type DType = 'float' | 'int' | 'str' | 'bool' | 'json' | 'series' | 'record' | 'list';
/**
* Something wired into this flow that is not a node in it.
@@ -390,6 +395,10 @@ export type MessagePoints = {
* :param port: The identifier the node function sees. Defaults to the last
* segment of ``name``, so unqualified flows read naturally.
* :param dtype: Payload type, validated on every message that passes through.
* :param item: The type of each item of a ``list`` port, ignored otherwise.
* Unset means ``record``, which is what the agenda and forecast widgets
* read; ``float`` is the numeric list a pipeline passes around. A list of
* lists, or of series, is refused — one declared level is the point.
* :param interval: Deliver at most every this many seconds; 0 is every time.
* On an output it holds back publishing, on an input it holds back waking
* the node. The value is never lost — state keeps the latest — only the
@@ -403,6 +412,7 @@ export type MessageSpec = {
name?: string;
port?: string;
dtype?: DType;
item?: (DType | null);
interval?: number;
trigger?: boolean;
};
@@ -764,10 +774,17 @@ export type ValidationResult = {
* ``config`` is per type — a chart names its series, a button names the
* message it publishes — and is validated against the type below rather than
* by a schema per class, because the whole set is small and closed.
*
* A chart comes in two kinds. The default reads what the engine kept for a
* message. One with ``source: "query"`` asks instead, and its config is
* ``{source, request, request_dtype: "record", message, dtype: "series",
* refresh_s, range_s}``: it publishes ``{range_s, interval_s}`` to
* ``request`` exactly as a slider publishes a value, and draws the ``series``
* a flow answers with on ``message``.
*/
export type WidgetDef = {
id: string;
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
title?: string;
layout?: {
[key: string]: Placement;
@@ -777,7 +794,7 @@ export type WidgetDef = {
};
};
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
export type AlertsReadAlertsConfigResponse = (AlertsConfig);
+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 =
+4 -1
View File
@@ -57,9 +57,12 @@ const FIELDS: Record<Channel["kind"], [string, string, string][]> = {
],
smtp: [["to", "Send to", "someone@example.com"]],
webhook: [["url", "URL", "https://example.com/hook"]],
// The message has to be one a flow declares, like anything a dashboard
// writes to. A notification widget bound to it is what shows the alert.
dashboard: [["message", "Message", "house.notice"]],
}
const KINDS: Channel["kind"][] = ["ntfy", "smtp", "webhook"]
const KINDS: Channel["kind"][] = ["ntfy", "smtp", "webhook", "dashboard"]
/** A setting may hold a `{"$secret": "name"}` reference rather than a literal,
* so text that parses as JSON is stored as JSON and survives a round trip. */