import { useQuery } from "@tanstack/react-query" import { Plus, X } from "lucide-react" import { useState } from "react" import type { MessageInfo, WidgetDef } from "@/client" import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker" import { PANEL_SECTION, PanelTitle, SidePanel, } from "@/components/Flow/SidePanel" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { cn } from "@/lib/utils" import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget" import { MAX_SERIES, refreshFor } from "./ChartWidget" import { CANVAS_PRESETS, COLUMN_CHOICES, canvasOf, columnsOf, type Dashboard, } from "./DashboardView" import { ICON_COLORS, ICON_NAMES } from "./icons" import { messageCatalogQueryOptions } from "./queries" import { acceptsDtype, INPUT_WIDGETS, type Series, seriesOf, WIDGET_LABELS, type WidgetKind, widgetIssue, } from "./widgets" const config = (widget: WidgetDef) => (widget.config ?? {}) as Record const str = (value: unknown) => (value == null ? "" : String(value)) /** Which messages this kind of widget may be pointed at. */ function choicesFor(kind: WidgetKind, catalog: MessageInfo[]): MessageInfo[] { const input = INPUT_WIDGETS.has(kind) return catalog.filter( (message) => acceptsDtype(kind, message.dtype) && // Flows own the namespace; a control can only set what one declares. (!input || message.writable !== false), ) } /** The picker, which records the payload type it bound along with the name. */ function MessagePicker({ kind, value, label, testId, filter, onPick, }: { kind: WidgetKind value: string label: string testId?: string /** What this slot takes, when the widget's own type does not decide it — * a querying chart asks with one shape and draws another. */ filter?: (message: MessageInfo) => boolean onPick: (message: string, dtype: string) => void }) { const { data } = useQuery(messageCatalogQueryOptions()) const catalog = data?.data ?? [] const choices = filter ? catalog.filter(filter) : choicesFor(kind, catalog) return (
) } /** * Where a chart's lines come from: what the engine kept, or what it asks for. * * The one segmented shape — a single border pill, transparent segments, * bg-accent on the selected one (root DESIGN-GUIDELINES.md). */ function ModePicker({ value, onChange, }: { value: "live" | "query" onChange: (mode: "live" | "query") => void }) { return (
Where the chart's data comes from {( [ ["live", "Live"], ["query", "Query"], ] as const ).map(([mode, label]) => ( ))}
) } /** Which chrome an input wears, in the same segmented shape as the mode. */ function StylePicker({ value, options, onChange, }: { value: string options: readonly (readonly [string, string])[] onChange: (style: string) => void }) { return (
How this control is drawn {options.map(([style, label]) => ( ))}
) } /** What a text field was meant to send: a bool, a number, or the text itself. */ function coerce(raw: string): unknown { const asNumber = Number(raw) if (raw === "true" || raw === "false") return raw === "true" return raw !== "" && Number.isFinite(asNumber) ? asNumber : raw } /** * What one widget shows or does. * * The same floating panel the flow editor uses for a node, so the two editors * read as one surface. */ export function WidgetPanel({ widget, onChange, onDelete, onClose, }: { widget: WidgetDef | null onChange: (changes: Partial) => void onDelete: () => void onClose: () => void }) { if (!widget) return null const cfg = config(widget) const isInput = INPUT_WIDGETS.has(widget.type) const issue = widgetIssue(widget) const set = (changes: Record) => onChange({ config: { ...cfg, ...changes } }) const series = seriesOf(widget) const setSeries = (next: Series[]) => set({ series: next }) // A bar's nested readings. An empty row stands in for none, so an unnested // bar still offers the picker rather than only a button. const segments = segmentsOf(widget) const rows: Segment[] = segments.length ? segments : [{}] // Always written as a list; `inner_dtype` belonged to the single binding a // bar carried before it stacked, and goes with it. const setSegments = (next: Segment[]) => set({ inner: next, inner_dtype: undefined }) // The icon widget's mapping. Position is the row's identity, as with series. const rules = (cfg.rules ?? []) as { at?: unknown icon?: string color?: string label?: string }[] const setRules = (next: typeof rules) => set({ rules: next }) // Nothing wrote these until now, so a dropdown's choices were uneditable. const options = (cfg.options ?? []) as { label?: string; value?: unknown }[] const setOptions = (next: typeof options) => set({ options: next }) const querying = cfg.source === "query" // What this chart would refresh at with nothing configured. The viewer can // pick another window on the widget, which moves it. const paced = refreshFor( RANGES.find((range) => range.hours * 3600 === Number(cfg.range_s)) ?? DEFAULT_RANGE, 0, ) return ( onChange({ title })} /> } footer={ } >
{WIDGET_LABELS[widget.type]} {issue ? (

{issue}

) : null} {widget.type === "markdown" ? (
set({ content: event.target.value })} />
) : widget.type === "chart" ? (
set({ source })} /> {querying ? (
message.dtype === "record" && message.writable !== false } onPick={(request, request_dtype) => set({ request, request_dtype }) } /> message.dtype === "series"} onPick={(message, dtype) => set({ message, dtype })} />
) : (
{series.map((entry, index) => (
setSeries( series.map((other, at) => at === index ? { ...other, message, dtype } : other, ), ) } />
setSeries( series.map((other, at) => at === index ? { ...other, label: event.target.value } : other, ), ) } />
))} {series.length < MAX_SERIES ? ( ) : null}
)}
) : // A clock reads the wall; a picker would bind a message nothing reads. widget.type === "clock" ? null : ( set({ [isInput ? "target" : "message"]: message, dtype }) } /> )}
{widget.type === "bar" ? (
{rows.map((segment, index) => (
setSegments( rows.map((other, at) => at === index ? { ...other, message, dtype } : other, ), ) } />
))} {rows.length < MAX_SEGMENTS ? ( ) : null}
) : null} {widget.type === "chart" && !querying ? (
set({ history: { points: Number(event.target.value) || 0 } }) } />

How much past the engine keeps for these messages.

) : null} {widget.type === "chart" && querying ? ( <>
set({ refresh_s: event.target.value === "" ? undefined : Number(event.target.value), }) } />

Empty follows the window — {paced} seconds at this range, since nothing changes until the bucket closes. A slower one is kept, a faster one only asks for the same picture twice.

A viewer can pick another on the widget itself.

) : null} {widget.type === "agenda" || widget.type === "forecast" ? (
set({ count: Number(event.target.value) || 0 }) } />
) : null} {widget.type === "chart" ? (
set({ y_min: event.target.value === "" ? undefined : Number(event.target.value), }) } />
set({ y_max: event.target.value === "" ? undefined : Number(event.target.value), }) } />
set({ y_label: event.target.value })} />
) : null} {widget.type === "stat" || widget.type === "gauge" || widget.type === "chart" || widget.type === "bar" || widget.type === "slider" ? (
set({ unit: event.target.value })} />
) : null} {widget.type === "gauge" || widget.type === "slider" || widget.type === "bar" ? (
set({ min: Number(event.target.value) || 0 }) } />
set({ max: Number(event.target.value) || 0 }) } />
{widget.type === "slider" ? (
set({ step: Number(event.target.value) || 1 }) } />
) : null}
) : null} {widget.type === "button" ? (
set({ value: coerce(event.target.value) })} />
) : null} {widget.type === "dropdown" ? (
Options {options.map((option, index) => (
setOptions( options.map((other, at) => at === index ? { ...other, label: event.target.value } : other, ), ) } /> setOptions( options.map((other, at) => at === index ? { ...other, value: coerce(event.target.value) } : other, ), ) } />
))}
) : null} {widget.type === "icon" ? (
Mapping {rules.map((rule, index) => (
setRules( rules.map((other, at) => at === index ? { ...other, at: coerce(event.target.value) } : other, ), ) } />
))}

Rows are checked top to bottom and the first match wins. A number also matches anything above it, so a descending ladder reads as thresholds.

) : null} {widget.type === "switch" || widget.type === "dropdown" ? (
{widget.type === "switch" ? ( set({ style })} /> ) : ( set({ style })} /> )}
) : null}
) } /** Presets are matched on their size, so a custom one simply matches none. */ const sizeKey = (size: { width: number; height: number }) => `${size.width}x${size.height}` /** * The dashboard's own settings, in the panel its widgets use. * * The canvas is the one that matters: a wall panel is a fixed size, and twelve * columns on a seven-inch screen is a different dashboard than twelve on a * television. */ export function DashboardPanel({ open, dashboard, widgetCount, onChange, onDelete, onClose, }: { open: boolean dashboard: Dashboard widgetCount: number onChange: (changes: Partial) => void onDelete: () => void onClose: () => void }) { const [confirmOpen, setConfirmOpen] = useState(false) const canvas = canvasOf(dashboard) return ( <> onChange({ title })} /> } footer={ } >
Grid

How many columns wide this dashboard is laid out, so it can be matched to the panel it will hang on.

Canvas
onChange({ canvas_width: Number(event.target.value) || 0 }) } /> onChange({ canvas_height: Number(event.target.value) || 0 }) } />

The panel this is drawn for, in pixels. Editing and viewing both scale that surface to fit, so the arrangement is the same everywhere.

Contents

{dashboard.name} —{" "} {widgetCount === 0 ? "nothing on it yet." : `${widgetCount} widget${widgetCount === 1 ? "" : "s"}.`}

Delete {dashboard.title || dashboard.name}? This removes the dashboard and its widgets. The flows it read from are untouched, and its history stays in the flow store's git repository. ) }