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:
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 20–22 °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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user