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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user