Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ezWcLmBCtTVKFPxfdK2MK
937 lines
31 KiB
TypeScript
937 lines
31 KiB
TypeScript
import { TriangleAlert } from "lucide-react"
|
|
import { memo, 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 { CounterWidget } from "./CounterWidget"
|
|
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 { PlayerWidget } from "./PlayerWidget"
|
|
import { usePublish } from "./publish"
|
|
import { useUi } from "./ui"
|
|
import { COLOR_DTYPES, colorFormatOf } from "./ui/core/color"
|
|
import {
|
|
buttonsOf,
|
|
config,
|
|
countersOf,
|
|
decimalsOf,
|
|
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",
|
|
"buttons",
|
|
"switch",
|
|
"slider",
|
|
"input",
|
|
"dropdown",
|
|
"color",
|
|
"counter",
|
|
])
|
|
|
|
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"],
|
|
// One number, the digits of which its counters are.
|
|
counter: ["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"],
|
|
// What a streamer says it is playing: title, artist, status, position and
|
|
// duration in one reading, because they are one thing and a player drawn
|
|
// from five separate messages would redraw itself five times.
|
|
player: ["record"],
|
|
// 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",
|
|
player: "Player",
|
|
embed: "Website",
|
|
button: "Button",
|
|
buttons: "Buttons",
|
|
switch: "Switch",
|
|
slider: "Slider",
|
|
input: "Input",
|
|
dropdown: "Selector",
|
|
color: "Colour",
|
|
counter: "Counter",
|
|
}
|
|
|
|
/** One line per type for the add palette, which searches these too. */
|
|
export const WIDGET_DESCRIPTIONS: Record<WidgetKind, string> = {
|
|
stat: "A single reading, large",
|
|
gauge: "A number against its range",
|
|
chart: "Readings over time",
|
|
markdown: "Formatted text, headings and links",
|
|
agenda: "Upcoming events from a calendar list",
|
|
notification: "The latest published title and message",
|
|
bar: "Several readings, a bar each",
|
|
icon: "An icon picked by the value it reads",
|
|
forecast: "What comes next, as a strip of columns",
|
|
clock: "Local time and date",
|
|
media: "A camera image, clip or audio",
|
|
player: "What a streamer is playing",
|
|
embed: "Another web page, framed",
|
|
button: "Sends a value when pressed",
|
|
buttons: "A grid of buttons, each its own value",
|
|
switch: "Turns something on or off",
|
|
slider: "Sets a number by dragging",
|
|
input: "Sends what is typed",
|
|
dropdown: "Picks one of a list of options",
|
|
color: "Picks a colour or brightness",
|
|
counter: "Sets a number by its digits, such as a time",
|
|
}
|
|
|
|
/** 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 },
|
|
player: { w: 4, h: 3 },
|
|
embed: { w: 6, h: 4 },
|
|
button: { w: 3, h: 2 },
|
|
buttons: { w: 4, h: 3 },
|
|
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 },
|
|
counter: { w: 3, h: 3 },
|
|
}
|
|
|
|
/** 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 {
|
|
// None 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 === "embed") {
|
|
return embedUrl(widget) ? null : "This widget has no address yet."
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// A player is the one widget that both reads and publishes, so it is the one
|
|
// whose wiring is only half done when a single picker is filled in.
|
|
if (widget.type === "player" && text(cfg.message) && !text(cfg.target)) {
|
|
return "This player does not publish to a message yet."
|
|
}
|
|
|
|
// A grid with nothing in it publishes nothing, however well it is bound.
|
|
if (widget.type === "buttons" && !buttonsOf(widget).length) {
|
|
return "This grid has no buttons yet."
|
|
}
|
|
|
|
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."
|
|
}
|
|
if (widget.type === "counter") {
|
|
const counters = countersOf(widget)
|
|
const name = (index: number) =>
|
|
counters[index].label || `Counter ${index + 1}`
|
|
// A size is what carries into the counter before it; without one there
|
|
// is nothing to carry at.
|
|
const unsized = counters.findIndex(
|
|
(counter, index) => index > 0 && !(Number(counter.size) >= 1),
|
|
)
|
|
if (unsized > 0) return `${name(unsized)} needs a size.`
|
|
const stalled = counters.findIndex(
|
|
(counter) => Number(counter.step ?? 1) < 1,
|
|
)
|
|
if (stalled >= 0) return `${name(stalled)} needs a step of 1 or more.`
|
|
}
|
|
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>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The page this widget embeds, or empty if it names none we will load.
|
|
*
|
|
* Only `http(s)`: an `<iframe src>` of `javascript:` or `data:` runs in this
|
|
* app's own origin, and a dashboard is a document several people can edit.
|
|
*/
|
|
export function embedUrl(widget: WidgetDef): string {
|
|
const url = text(config(widget).url).trim()
|
|
return /^https?:\/\//i.test(url) ? url : ""
|
|
}
|
|
|
|
/**
|
|
* Whether *url* is on *origin*: the one a trusted embed must not load.
|
|
*
|
|
* `allow-same-origin` lets a page keep its own origin, which for another site
|
|
* is only what visiting it would do. For a page on the origin this app runs on
|
|
* — its own, or the hub's when it is served through one — it means scripts
|
|
* that can reach into the app and read its session. An address that does not
|
|
* parse counts as one.
|
|
*/
|
|
export function onOrigin(url: string, origin: string): boolean {
|
|
try {
|
|
return new URL(url).origin === origin
|
|
} catch {
|
|
return true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Somebody else's page, drawn in the tile.
|
|
*
|
|
* Whatever the site serves — a weather map, a camera's own web UI, a timetable
|
|
* — with no binding of its own, so nothing has to be modelled as a message
|
|
* first. A site that refuses to be framed (`X-Frame-Options`) shows its own
|
|
* refusal inside the tile; that is between the browser and the site.
|
|
*
|
|
* By default the page runs in an opaque origin, with no cookies or storage of
|
|
* its own. **Trusted** keeps it in its own origin, which most interactive
|
|
* sites need to start (the demo's weather map renders an unstyled list
|
|
* without it), and is refused for this app's own origin.
|
|
*
|
|
* **Image** draws the address as a picture scaled to the tile instead: a
|
|
* camera's MJPEG stream or snapshot, which a frame would show at its native
|
|
* size. An image runs nothing, so it needs no sandbox.
|
|
*/
|
|
function EmbedWidget({ widget }: WidgetProps) {
|
|
const url = embedUrl(widget)
|
|
const trusted = config(widget).trusted === true
|
|
if (!url) return <p className="text-muted-foreground">Give it an address.</p>
|
|
if (config(widget).image === true) {
|
|
return (
|
|
<img
|
|
src={url}
|
|
alt={widget.title || url}
|
|
referrerPolicy="no-referrer"
|
|
className="h-full w-full rounded-md object-contain"
|
|
/>
|
|
)
|
|
}
|
|
if (trusted && onOrigin(url, window.location.origin)) {
|
|
return (
|
|
<p className="text-muted-foreground" data-testid="embed-refused">
|
|
This app's own address cannot be a trusted site.
|
|
</p>
|
|
)
|
|
}
|
|
return (
|
|
<iframe
|
|
// A changed sandbox applies from the next navigation only, so a toggle
|
|
// reloads the frame rather than leaving the old one running.
|
|
key={trusted ? "trusted" : "sandboxed"}
|
|
src={url}
|
|
title={widget.title || url}
|
|
sandbox={`allow-scripts allow-popups allow-forms${trusted ? " allow-same-origin" : ""}`}
|
|
referrerPolicy="no-referrer"
|
|
loading="lazy"
|
|
className="h-full w-full rounded-md border-0"
|
|
/>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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>
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Several stateless presses, one message.
|
|
*
|
|
* A grid rather than one tile per press: six presets are six tiles to place
|
|
* and six titles to read, and what they have in common — where they publish —
|
|
* is then repeated six times. Nothing is read back, exactly as for a single
|
|
* button: what these send is an instruction, and the last one sent is not a
|
|
* state anything can be drawn as.
|
|
*/
|
|
function ButtonsWidget({ widget, dashboard }: WidgetProps) {
|
|
const { Button } = useUi()
|
|
const { target, send, pending, locked, pulse } = usePublish(widget, dashboard)
|
|
const buttons = buttonsOf(widget)
|
|
if (!target) return <Unbound />
|
|
|
|
return (
|
|
<>
|
|
{pulse}
|
|
<div className="widget-buttons">
|
|
{buttons.map((button, index) => (
|
|
<Button
|
|
// Two presses may share a label and a value; position is the
|
|
// identity, as it is for a chart's series and a bar's rows.
|
|
key={`button-${index}`}
|
|
variant="tonal"
|
|
disabled={pending || locked}
|
|
onClick={() => send(button.value ?? button.label ?? true)}
|
|
>
|
|
{text(button.label, text(button.value, "Send"))}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
// What it sets against: a room's temperature beside its setpoint.
|
|
const beside = (cfg.reading ?? {}) as Series
|
|
const read = useBoundValue(beside.message || undefined)
|
|
if (!target) return <Unbound />
|
|
|
|
const unit = cfg.unit ? text(cfg.unit) : undefined
|
|
const vertical = cfg.orientation === "vertical"
|
|
const reading = typeof value === "number" ? value : min
|
|
// Both the value and what it is set against are written to the step's
|
|
// decimals, as a round face writes them, so the two always match.
|
|
const digits = decimalsOf(num(cfg.step, 1))
|
|
const control = (
|
|
<Slider
|
|
value={reading}
|
|
min={min}
|
|
max={num(cfg.max, 100)}
|
|
step={num(cfg.step, 1)}
|
|
unit={unit}
|
|
orientation={vertical ? "vertical" : "horizontal"}
|
|
// 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. A
|
|
// column has no room for them at all.
|
|
ticks={cfg.ticks !== false}
|
|
mark={typeof read?.value === "number" ? read.value : null}
|
|
label={widget.title || target}
|
|
disabled={locked}
|
|
onCommit={send}
|
|
/>
|
|
)
|
|
const readout = (
|
|
<Readout value={reading} precision={digits} unit={unit} size="inline" />
|
|
)
|
|
// A line under the value, smaller, so the value being set is still the one
|
|
// that reads. In the slider's unit and to its step's decimals, as a round
|
|
// face writes it: the two are read against each other. Its mark on the
|
|
// track is the same reading, placed.
|
|
const against = beside.message ? (
|
|
<span
|
|
data-testid="slider-reading"
|
|
className="flex h-4 max-w-full items-center gap-1.5 text-xs"
|
|
>
|
|
{beside.label ? (
|
|
<span className="min-w-0 max-w-24 truncate text-muted-foreground">
|
|
{beside.label}
|
|
</span>
|
|
) : null}
|
|
{/* The label gives way first: the number is what the line is for. */}
|
|
<span className="shrink-0">
|
|
<Readout
|
|
value={read?.value}
|
|
precision={digits}
|
|
unit={unit}
|
|
size="inline"
|
|
/>
|
|
</span>
|
|
</span>
|
|
) : null
|
|
|
|
// A column takes the tile's height and puts the reading under it, which is
|
|
// what a fader looks like. A volume control is the case it exists for: it is
|
|
// reached for without looking, and up is louder.
|
|
if (vertical) {
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col items-center gap-2">
|
|
{pulse}
|
|
{control}
|
|
<div className="flex max-w-full shrink-0 flex-col items-center">
|
|
{readout}
|
|
{against}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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">{control}</div>
|
|
{/* The reading, if any, under the value: in the ticks' row when there
|
|
is one, so it costs the tile no height. Held to half the row, so a
|
|
long label in a small tile cannot squeeze the track to nothing. */}
|
|
<div
|
|
className={cn(
|
|
"flex shrink-0 flex-col items-end",
|
|
against && "max-w-1/2",
|
|
)}
|
|
>
|
|
{/* 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)] items-center">
|
|
{readout}
|
|
</div>
|
|
{against}
|
|
</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,
|
|
player: PlayerWidget,
|
|
embed: EmbedWidget,
|
|
button: ButtonWidget,
|
|
buttons: ButtonsWidget,
|
|
switch: SwitchWidget,
|
|
slider: SliderWidget,
|
|
input: InputWidget,
|
|
dropdown: DropdownWidget,
|
|
color: ColorWidget,
|
|
counter: CounterWidget,
|
|
}
|
|
|
|
function WidgetBodyComponent({ 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>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* What a tile draws, held across a render of the grid around it.
|
|
*
|
|
* Both props stay put while the document does — a widget keeps its identity,
|
|
* and the dashboard is a name — so a grid re-rendered for reasons of its own no
|
|
* longer rebuilds every chart with it. Nothing a tile watches is held back by
|
|
* the boundary: its bound value, the look and the palette all reach it below
|
|
* here, through its own subscriptions.
|
|
*/
|
|
export const WidgetBody = memo(WidgetBodyComponent)
|
|
|
|
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>
|
|
)
|
|
}
|