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);