Files
app/frontend/src/components/Dashboard/widgets.tsx
T
stroblmeandClaude Opus 5 1873787ee6 Compress what the browser downloads, and stop one tile taking the page
**nginx served the bundle uncompressed and uncacheable.** The base image
ships gzip commented out and nothing set `Cache-Control`, so every load
carried the whole thing and every reload cost a 304 per asset. Measured on
the built image: the entry chunk 838 kB → 311 kB, the Monaco chunk 2.66 MB
→ 832 kB, and the ~50 content-hashed assets are now immutable for a year.
`index.html` is explicitly `no-cache`, since it is what names the rest.

**A widget that throws no longer blanks the screen.** There was one error
boundary in the app, on the root route, so anything that threw replaced
everything including the navigation — on `/view/{name}`, an unattended wall
panel with no way back. Each tile has its own boundary now, and the app
shell has one inside it so a screen that fails leaves the sidebar standing.
`react-error-boundary` was already a dependency and imported nowhere.

**`localStorage` cannot take the app down.** Reaching it raises where the
browser blocks site data, and `setItem` raises once the origin's quota is
full — which the flow editor's node clipboard, carrying whole Python
sources, can genuinely reach. Thrown from a key handler that escaped to
`window.onerror`, which the single root boundary then turned into a blank
page. `lib/safeStorage.ts` is the guarded pair the pre-paint theme script in
`index.html` was already using; a copy too large to store now says so.

Queries default to `staleTime: 5000` — below every poll interval on any
screen, so nothing polls less often than it did, but a route mounting twice
in a few seconds stops refetching everything it touches. Window-focus
refetching is off: the socket pushes what changes and a reconnect
invalidates what it feeds, so a focus event has nothing of its own to say.
Home alone reads about ten queries on every one of those.

Render cost, two that showed up in the audit:

- `LogsPanel` was rendered unconditionally by the dock and decided inside
  itself whether to draw, so with the panel *shut* it still subscribed to
  the log store and re-filtered five hundred lines per line a flow
  published. It returns before any of that now.
- `HealthActivity` subscribed to the whole engine-event array and used one
  number from it, so a flapping node re-rendered the component that draws
  Home's two uPlot charts — each of which rebuilds its series on every
  render by design. It subscribes to that number.
- the global search bucketed the index nine times per keystroke, once per
  group. One pass, and each group offers at most twenty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
2026-08-29 21:00:02 +02:00

661 lines
21 KiB
TypeScript

import { TriangleAlert } from "lucide-react"
import { useState } from "react"
import { ErrorBoundary } from "react-error-boundary"
import type { WidgetDef } from "@/client"
import { cn } from "@/lib/utils"
import { BarWidget } from "./BarWidget"
import { ChartWidget } from "./ChartWidget"
import { ClockWidget } from "./ClockWidget"
import { ColorWidget } from "./ColorWidget"
import { useBoundValue } from "./dataContext"
// The grid's own rules live beside the components that use them; CSS is
// chunked per entry, so the sheet is pulled in wherever a widget is drawn.
import "./dashboard.css"
import { ForecastWidget } from "./ForecastWidget"
import { IconWidget } from "./IconWidget"
import { MediaWidget } from "./MediaWidget"
import { usePublish } from "./publish"
import { useUi } from "./ui"
import { COLOR_DTYPES, colorFormatOf } from "./ui/core/color"
import { config, num, rowsOf, text } from "./ui/core/config"
/** Widget types that put a value into the graph rather than read one. */
export const INPUT_WIDGETS = new Set([
"button",
"switch",
"slider",
"input",
"dropdown",
"color",
])
export type WidgetKind = WidgetDef["type"]
/**
* What a widget can be pointed at, by payload type.
*
* A switch that reads a float has nothing to show and nothing safe to send, so
* the pairing is part of the document rather than a matter of taste. The same
* table is enforced on the server (`app/flow/dashboards.py`); a type missing
* from it takes anything.
*/
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"],
bar: ["float", "int"],
forecast: ["list"],
// Either shape a colour can travel as; its `format` decides which of the two
// this widget means, which `widgetIssue` holds the binding to.
color: ["list", "str"],
// A camera frame, a clip, a segment. What it draws follows the type it is
// bound to; a plain artifact is taken as well, since the media type on the
// reference is what says what the bytes are.
media: ["image", "audio", "video", "artifact"],
// An icon maps weather strings, bool hints and numbers alike, and a clock
// binds nothing at all, so neither has a row to be held to.
}
/** Whether a message of this payload type may drive this kind of widget. */
export function acceptsDtype(kind: WidgetKind, dtype: string | undefined) {
const allowed = WIDGET_DTYPES[kind]
return !allowed || !dtype || allowed.includes(dtype)
}
export const WIDGET_LABELS: Record<WidgetKind, string> = {
stat: "Value",
gauge: "Gauge",
chart: "Chart",
markdown: "Text",
agenda: "Agenda",
notification: "Notification",
bar: "Bar",
icon: "Icon",
forecast: "Forecast",
clock: "Clock",
media: "Media",
button: "Button",
switch: "Switch",
slider: "Slider",
input: "Input",
dropdown: "Selector",
color: "Colour",
}
/** Default footprint per type, in grid units. */
export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
stat: { w: 3, h: 2 },
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 },
bar: { w: 4, h: 2 },
icon: { w: 2, h: 2 },
forecast: { w: 6, h: 2 },
clock: { w: 3, h: 2 },
media: { w: 4, h: 4 },
button: { w: 3, h: 2 },
switch: { w: 3, h: 2 },
slider: { w: 4, h: 2 },
input: { w: 4, h: 2 },
dropdown: { w: 4, h: 2 },
color: { w: 4, h: 4 },
}
/** One line of a chart, as the document stores it. */
export type Series = { message?: string; dtype?: string; label?: string }
export const seriesOf = (widget: WidgetDef): Series[] =>
(config(widget).series ?? []) as Series[]
/**
* What is wrong with this widget's wiring, if anything.
*
* Both halves are checked from the document alone — the picker records the
* payload type it bound — so a wall panel can flag a broken tile without
* fetching the message catalogue first.
*/
export function widgetIssue(widget: WidgetDef): string | null {
// Neither draws a message: a clock reads the wall, markdown its own text.
if (widget.type === "markdown" || widget.type === "clock") return null
const cfg = config(widget)
if (widget.type === "chart" && cfg.source === "runs") {
const runs = (cfg.runs ?? {}) as {
metric?: string
flow?: string
group?: string
ids?: string[]
}
if (!runs.metric) return "This chart names no run metric yet."
if (!runs.ids?.length && !runs.group && !runs.flow) {
return "Say which runs: a flow, a sweep, or run ids."
}
return null
}
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."
const wrong = series.find(
(entry) => !entry.message || !acceptsDtype("chart", entry.dtype),
)
if (wrong) {
return wrong.message
? `${wrong.message} is a ${wrong.dtype}; a chart can only draw numbers.`
: "One of the series is not bound to a message."
}
return null
}
// A bar draws a row per reading, so it is judged row by row — the same way
// a chart is judged series by series.
if (widget.type === "bar") {
const rows = rowsOf(widget)
if (rows.length === 0) return "This bar has no rows yet."
const wrong = rows.find(
(row) => !row.message || !acceptsDtype("bar", row.dtype),
)
if (wrong) {
return wrong.message
? `${wrong.message} is a ${wrong.dtype}; a bar draws numbers.`
: "One of the rows is not bound to a message."
}
return null
}
// A media tile playing a camera's own stream binds no message: the browser
// fetches it from the source, and the engine is not in the way of it.
if (widget.type === "media" && text(cfg.stream_url) && !text(cfg.message)) {
return null
}
const input = INPUT_WIDGETS.has(widget.type)
const bound = text(cfg[input ? "target" : "message"])
if (!bound) {
return input
? "This control does not publish to a message yet."
: "This widget is not bound to a message yet."
}
const dtype = cfg.dtype === undefined ? undefined : text(cfg.dtype)
if (!acceptsDtype(widget.type, dtype)) {
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.`
}
if (widget.type === "color") {
const want = COLOR_DTYPES[colorFormatOf(widget)]
if (dtype && dtype !== want) {
return `${bound} is a ${dtype}; this disc sends ${colorFormatOf(widget)}, which is a ${want}.`
}
}
if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) {
return "This icon has nothing mapped yet."
}
return null
}
function Unbound() {
return <p className="text-muted-foreground">Pick a message.</p>
}
// ---------------------------------------------------------------------------
// Display
// ---------------------------------------------------------------------------
function StatWidget({ widget }: WidgetProps) {
const { Readout } = useUi()
const cfg = config(widget)
const message = text(cfg.message)
const live = useBoundValue(message || undefined)
if (!message) return <Unbound />
return (
<Readout
value={live?.value}
precision={cfg.precision === undefined ? null : num(cfg.precision, 1)}
unit={cfg.unit ? text(cfg.unit) : undefined}
/>
)
}
/**
* A dial, drawn as an arc.
*
* The number is always written out as well: a reading that only exists as an
* angle is unreadable to anyone who cannot judge one.
*/
function GaugeWidget({ widget }: WidgetProps) {
const { Gauge } = useUi()
const cfg = config(widget)
const message = text(cfg.message)
const live = useBoundValue(message || undefined)
if (!message) return <Unbound />
return (
<Gauge
value={typeof live?.value === "number" ? live.value : null}
min={num(cfg.min, 0)}
max={num(cfg.max, 100)}
precision={cfg.precision === undefined ? 1 : num(cfg.precision, 1)}
unit={cfg.unit ? text(cfg.unit) : undefined}
label={widget.title || message}
/>
)
}
/**
* A very small markdown subset, read a line at a time: headings and list items.
* Inline spans — bold, code, links — are not parsed and read as written.
*
* Enough for the labels and notes a dashboard carries, and not worth a parser.
*/
function MarkdownWidget({ widget }: WidgetProps) {
const content = text(config(widget).content)
const lines = content.split("\n")
return (
<div className="grid gap-1 break-words">
{lines.map((line, index) => {
const heading = /^(#{1,3})\s+(.*)$/.exec(line)
const body = heading ? heading[2] : line.replace(/^[-*]\s+/, "")
const bullet = !heading && /^[-*]\s+/.test(line)
return (
<p
// Plain text: position is the only identity a line has.
key={`line-${index}`}
className={cn(
heading?.[1] === "#" && "font-medium",
heading?.[1] === "##" && "font-medium",
heading?.[1] === "###" && "text-muted-foreground",
bullet && "pl-4",
)}
// Relative to the widget's own size, so an inline value rather
// than an arbitrary Tailwind class.
style={heading?.[1] === "#" ? { fontSize: "1.35em" } : undefined}
>
{bullet ? "• " : ""}
{body}
</p>
)
})}
</div>
)
}
/** 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 = useBoundValue(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-muted-foreground">Nothing coming up.</p>
}
return (
// The frame centres a single reading in its card; a list reads from the
// top, so it takes the slack below it.
<ul className="mb-auto grid gap-1.5">
{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 min-w-0 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 = useBoundValue(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-muted-foreground">Nothing to report.</p>
}
const failed = record?.severity === "error"
return (
<div className="grid gap-1 break-words">
{title ? (
<p
className={cn(
"flex items-baseline gap-1.5 font-medium",
failed && "text-destructive",
)}
>
{/* Never colour alone: a notice that went wrong says so in a word as
well as in red. */}
{failed ? <span className="sr-only">Error: </span> : null}
{failed ? <span aria-hidden></span> : null}
{title}
</p>
) : null}
{body ? <p className="text-muted-foreground">{body}</p> : null}
</div>
)
}
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
function ButtonWidget({ widget, dashboard }: WidgetProps) {
const { Button } = useUi()
const cfg = config(widget)
const { target, send, pending, pulse, locked } = usePublish(widget, dashboard)
if (!target) return <Unbound />
// Nothing to hold: a button carries no reading, so the pulse and a refusal
// are the whole of its feedback.
return (
<>
{pulse}
<Button
variant="tonal"
disabled={pending || locked}
onClick={() => send(cfg.value ?? true)}
>
{text(cfg.label, widget.title || "Send")}
</Button>
</>
)
}
/**
* A bool, published and read back — a latch either way it is drawn.
*
* `style: "button"` is a control that stays in rather than a track; both name
* the state in words, because a fill alone does not say what it means.
*/
function SwitchWidget({ widget, dashboard }: WidgetProps) {
const { Button, Switch } = useUi()
const cfg = config(widget)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
if (!target) return <Unbound />
const on = value === true
return cfg.style === "button" ? (
<>
{pulse}
<Button
variant={on ? "filled" : "tonal"}
pressed={on}
label={widget.title || target}
disabled={locked}
onClick={() => send(!on)}
>
{on ? "On" : "Off"}
</Button>
</>
) : (
<div className="flex items-center justify-between gap-2">
{pulse}
<span>{on ? "On" : "Off"}</span>
<Switch
checked={on}
label={widget.title || target}
disabled={locked}
onChange={(checked) => send(checked)}
/>
</div>
)
}
function SliderWidget({ widget, dashboard }: WidgetProps) {
const { Readout, Slider } = useUi()
const cfg = config(widget)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const min = num(cfg.min, 0)
if (!target) return <Unbound />
const unit = cfg.unit ? text(cfg.unit) : undefined
return (
// Value beside the track rather than above it, the way a bar row reads —
// one row instead of two, and the tile keeps the height for the control.
<div className="flex items-start gap-3">
{pulse}
<div className="min-w-0 flex-1">
<Slider
value={typeof value === "number" ? value : min}
min={min}
max={num(cfg.max, 100)}
step={num(cfg.step, 1)}
unit={unit}
// On by default, because a slider without them says how far along it
// is and not what that means. Off is for a short tile: they are the
// last row of a control that has three, and on a seven-inch panel
// that row is the difference between fitting and being cut off.
ticks={cfg.ticks !== false}
label={widget.title || target}
disabled={locked}
onCommit={send}
/>
</div>
{/* Held to the control's own height, so the value sits on the track's
midline whether or not there is a row of ticks under it. */}
<div className="flex h-[var(--dui-control)] shrink-0 items-center">
<Readout
value={typeof value === "number" ? value : min}
precision={null}
unit={unit}
size="inline"
/>
</div>
</div>
)
}
function InputWidget({ widget, dashboard }: WidgetProps) {
const { Input } = useUi()
const cfg = config(widget)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const [draft, setDraft] = useState<string | null>(null)
if (!target) return <Unbound />
const asNumber = cfg.dtype === "float" || cfg.dtype === "int"
return (
<>
{pulse}
<Input
value={draft ?? text(value)}
type={asNumber ? "number" : "text"}
label={widget.title || target}
disabled={locked}
onChange={setDraft}
onCommit={() => {
if (draft === null) return
send(asNumber ? Number(draft) || 0 : draft)
setDraft(null)
}}
/>
</>
)
}
/**
* One of N, published and read back.
*
* `style: "segmented"` shows every choice at once with the active one held —
* the same exclusive group, drawn for a panel that is looked at across a room.
*/
function DropdownWidget({ widget, dashboard }: WidgetProps) {
const { Segmented, Select } = useUi()
const cfg = config(widget)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
if (!target) return <Unbound />
if (cfg.style === "segmented") {
return (
<>
{pulse}
<Segmented
value={text(value)}
options={options.map(
(option) =>
[text(option.value), option.label ?? text(option.value)] as const,
)}
label={widget.title || target}
disabled={locked}
onChange={(picked) =>
send(options.find((o) => text(o.value) === picked)?.value ?? picked)
}
/>
</>
)
}
return (
<>
{pulse}
<Select
value={text(value)}
options={options}
label={widget.title || target}
disabled={locked}
onChange={send}
/>
</>
)
}
// ---------------------------------------------------------------------------
export type WidgetProps = { widget: WidgetDef; dashboard: string }
const RENDERERS: Partial<
Record<WidgetKind, (props: WidgetProps) => React.ReactNode>
> = {
stat: StatWidget,
gauge: GaugeWidget,
chart: ChartWidget,
markdown: MarkdownWidget,
agenda: AgendaWidget,
notification: NotificationWidget,
bar: BarWidget,
icon: IconWidget,
forecast: ForecastWidget,
clock: ClockWidget,
media: MediaWidget,
button: ButtonWidget,
switch: SwitchWidget,
slider: SliderWidget,
input: InputWidget,
dropdown: DropdownWidget,
color: ColorWidget,
}
export function WidgetBody({ widget, dashboard }: WidgetProps) {
const Renderer = RENDERERS[widget.type]
if (!Renderer) {
return (
<p className="text-muted-foreground">
{WIDGET_LABELS[widget.type]} widgets are not drawn yet.
</p>
)
}
return (
// Per tile, because a dashboard is often the only thing on a screen
// nobody is standing at: one widget whose config the renderer cannot make
// sense of used to throw past every ancestor and leave a wall panel
// blank, with no navigation to recover from.
<ErrorBoundary fallback={<WidgetFailed />} resetKeys={[widget.id]}>
<Renderer widget={widget} dashboard={dashboard} />
</ErrorBoundary>
)
}
function WidgetFailed() {
return (
<p className="flex h-full items-center justify-center gap-2 text-muted-foreground text-sm">
<TriangleAlert className="size-4 shrink-0" aria-hidden />
This tile could not be drawn.
</p>
)
}