**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
661 lines
21 KiB
TypeScript
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>
|
|
)
|
|
}
|