import { useQuery } from "@tanstack/react-query" import { Ban, ChevronDown, Plus, RotateCw, X } from "lucide-react" import { useState } from "react" import type { MessageInfo, SettingDef, WidgetDef } from "@/client" import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker" import { PANEL_SECTION, PanelTitle, SidePanel, } from "@/components/Flow/SidePanel" import { runOverviewQueryOptions } from "@/components/Runs/queries" 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 { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { Segmented } from "@/components/ui/segmented" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" import { MAX_SERIES, refreshFor } from "./ChartWidget" import { COLOR_DTYPES, COLOR_FORMATS, colorFormatOf } from "./ColorWidget" import { CANVAS_PRESETS, COLUMN_CHOICES, canvasOf, columnsOf, type Dashboard, } from "./DashboardView" import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons" import { messageCatalogQueryOptions } from "./queries" import { LOOK_CHOICES, lookOf, SETTING_DTYPES, type SettingName, settingIssue, settingOf, THEME_CHOICES, } from "./settings" import { type BarRow, MAX_ROWS, rowsOf, showTitle } from "./ui/core/config" import { parsePalette, roleLabel } from "./ui/core/theme" 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)) /** A number field left blank inherits rather than reading as zero. */ const numberOrNone = (raw: string) => raw.trim() === "" ? undefined : Number(raw) /** 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, placeholder = "Pick a message", filter, onPick, }: { /** Omitted where the slot is not a widget's at all — a dashboard setting * binds by payload type alone, and hands in `filter` instead. */ kind?: WidgetKind value: string label: string testId?: string placeholder?: 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) : kind ? choicesFor(kind, catalog) : catalog return (
) } /** Which runs a pinned chart draws, and which of their metrics. */ type RunsPick = { metric?: string flow?: string group?: string ids?: string[] latest?: number } /** How a pinned chart says which runs it means. */ const RUN_PICKS = [ ["latest", "Latest"], ["group", "Sweep"], ["ids", "Named"], ] as const /** * The runs a chart is pinned to. * * The flows offered are the ones that have actually run — a flow with no runs * has no curve to draw, and the run tables already know which those are. */ function RunsSourceFields({ runs, refreshS, onChange, onRefresh, }: { runs: RunsPick refreshS: unknown onChange: (next: RunsPick) => void onRefresh: (seconds: number) => void }) { const { data: flows } = useQuery(runOverviewQueryOptions()) const mode = runs.ids?.length ? "ids" : runs.group ? "group" : "latest" return (
(message.dtype === "float" || message.dtype === "int") && (!runs.flow || message.name.startsWith(`${runs.flow}.`)) } onPick={(metric) => onChange({ ...runs, metric })} /> onChange({ ...runs, group: undefined, ids: undefined, latest: picked === "latest" ? (runs.latest ?? 3) : undefined, }) } /> {mode === "latest" && (
onChange({ ...runs, latest: Number(event.target.value) }) } />
)} {mode === "group" && (
onChange({ ...runs, group: event.target.value }) } />
)} {mode === "ids" && (
onChange({ ...runs, ids: event.target.value .split(",") .map((one) => one.trim()) .filter(Boolean), }) } />
)}
onRefresh(Number(event.target.value))} />
) } /** Where a chart's lines come from: what the engine kept, or what it asks for. */ const CHART_SOURCES = [ ["live", "Live"], ["query", "Query"], ["runs", "Runs"], ] as const /** * Pick one of the tile glyphs, or none. * * A grid of the glyphs themselves rather than a list of their names: an icon is * chosen by how it looks. Clearing is a button rather than an option, because * Radix forbids an empty `SelectItem` value — which is why the selects this * replaces could set an icon but never take one back. * * ponytail: no filter field. `ICONS` is a few dozen and the grid shows all of * it without scrolling; add one when the map outgrows a popover. */ function IconPicker({ value, placeholder, label, testId, className, onChange, }: { value: string /** What no icon gets you, on the trigger and on the clearing button. */ placeholder: string /** Names the trigger for screen readers. */ label: string testId?: string className?: string onChange: (icon: string) => void }) { const [open, setOpen] = useState(false) const Current = ICONS[value] return (
{ICON_NAMES.map((name) => { const Glyph = ICONS[name] return ( ) })}
) } /** 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 runsPick = (cfg.runs ?? {}) as RunsPick const setSeries = (next: Series[]) => set({ series: next }) // A bar's readings. An empty row stands in for none, so a fresh bar offers // the picker rather than only a button. const bound = rowsOf(widget) const rows: BarRow[] = bound.length ? bound : [{}] /** Writing rows is also what retires the shape a bar was stored in before. */ const setRows = (next: BarRow[]) => set({ rows: next, message: undefined, dtype: undefined, inner: undefined, inner_dtype: undefined, inner_label: 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" const pinnedRuns = cfg.source === "runs" // 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} {/* The title is still the widget's name — what a screen reader calls its controls, and what a publish is labelled with. This is only whether the panel draws it: a row of gauges under one heading reads better without four repeated captions above them. */}
Show title set({ show_title: value })} />
{widget.type === "markdown" ? (
set({ content: event.target.value })} />
) : widget.type === "chart" ? (
set({ source })} /> {pinnedRuns ? ( set({ runs })} onRefresh={(refresh_s) => set({ refresh_s })} /> ) : 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. A bar binds a row at a time, below. widget.type === "clock" || widget.type === "bar" ? null : ( message.dtype === COLOR_DTYPES[colorFormatOf(widget)] && message.writable !== false : undefined } onPick={(message, dtype) => set({ [isInput ? "target" : "message"]: message, dtype }) } /> )}
{widget.type === "bar" ? (
{rows.map((row, index) => (
setRows( rows.map((other, at) => at === index ? { ...other, message, dtype } : other, ), ) } />
setRows( rows.map((other, at) => at === index ? { ...other, label: event.target.value } : other, ), ) } />
{/* Blank inherits the widget's own scale below, which is what a bar of comparable readings wants. A row that measures something else — a percentage beside a load in kW — says so here. */}
setRows( rows.map((other, at) => at === index ? { ...other, min: numberOrNone(event.target.value), } : other, ), ) } /> setRows( rows.map((other, at) => at === index ? { ...other, max: numberOrNone(event.target.value), } : other, ), ) } /> setRows( rows.map((other, at) => at === index ? { ...other, unit: event.target.value || undefined, } : other, ), ) } />
))} {rows.length < MAX_ROWS ? ( ) : 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 === "chart" ? (
Smoothing set({ smooth })} />

Curves the lines between readings. It draws them softer; it does not change what was measured.

) : 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, ), ) } /> setRules( rules.map((other, at) => at === index ? { ...other, icon } : other, ), ) } /> setRules( rules.map((other, at) => at === index ? { ...other, label: 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.

set({ icon })} />
) : null} {widget.type === "color" ? (
set({ format, // Both triples are a `list` and hex is a `str`, so a change // between the two shapes takes the binding with it rather // than leaving a message this control can no longer carry. ...(COLOR_DTYPES[format as keyof typeof COLOR_DTYPES] === str(cfg.dtype) ? {} : { target: "", dtype: undefined }), }) } />

HSV is{" "} [h 0-360, s 0-100, v 0-100], RGB [r, g, b] 0-255, Hex{" "} "#rrggbb".

) : 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 optional half of a setting: which message, if any, drives it. * * Deliberately drawn as an addition rather than a requirement — the placeholder * says what leaving it alone means, and the value control above it is the whole * setting until something is picked here. Binding is how a flow takes the * setting over; a node publishing on a cron is what a schedule is in this * system, so there is no scheduling UI to build. * * The picker records the payload type beside the name, which is what lets the * pairing be judged from the document alone — the same rule a widget's binding * is held to, and the same one the server enforces. */ function SettingBinding({ name, setting, onChange, }: { name: SettingName setting: SettingDef onChange: (setting: SettingDef) => void }) { const want = SETTING_DTYPES[name] const issue = settingIssue(name, setting) return (
message.dtype === want} onPick={(message, dtype) => onChange({ ...setting, message, dtype }) } />
{setting.message ? ( ) : null}
{issue ? (

{issue}

) : null}
) } /** * The dashboard's colours, pasted or typed. * * A link is the fastest way to a palette somebody already likes, so anything * with hex in it is read — a coolors.co link, a colorhunt one, a comma list, a * column of `#rrggbb`. What is kept is the order, because order is the role. * * The text is held locally so a half-typed link is not fought over while it is * being typed; the parsed colours are written through on every keystroke, which * is what makes pasting a link show the dashboard immediately. */ function PalettePicker({ palette, onChange, }: { palette: string[] onChange: (palette: string[]) => void }) { const [draft, setDraft] = useState(palette.join(" ")) const write = (text: string) => { setDraft(text) const next = parsePalette(text) if (next.join(" ") !== palette.join(" ")) onChange(next) } return (
write(event.target.value)} /> {palette.length === 0 ? (

No palette — this dashboard keeps the app's own colours.

) : (
{palette.map((hex, index) => ( {roleLabel(index)} ))} {/* The roles are positional, and a palette rarely arrives in the order a dashboard wants them. Turning it is quicker than retyping five colours. */}
)}
) } /** How a setting's own value reads in the "nothing is driving it" line. */ function valueLabel(name: SettingName, value: unknown): string { if (name === "locked") return value === true ? "read-only" : "editable" if (name === "touch") return value === true ? "touch friendly" : "pointer" if (name === "look") return lookOf(value) if (name === "background") return value ? "that image" : "no image" if (name === "palette") { const count = parsePalette(value).length return count ? `those ${count} colours` : "no palette" } const chosen = THEME_CHOICES.find(([option]) => option === value) return (chosen?.[1] ?? "System").toLowerCase() } /** * 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) const theme = settingOf(dashboard, "theme") const locked = settingOf(dashboard, "locked") const look = settingOf(dashboard, "look") const background = settingOf(dashboard, "background") const touch = settingOf(dashboard, "touch") const paletteSetting = settingOf(dashboard, "palette") const palette = parsePalette(paletteSetting.value) /** Settings are a map, so one of them changing rewrites the whole of it. */ const setSetting = (name: SettingName, setting: SettingDef) => onChange({ settings: { ...(dashboard.settings ?? {}), [name]: setting } }) return ( <> onChange({ title })} /> } footer={ } >
Rail icon onChange({ icon })} />

Drawn on the rail when a panel carries more than one dashboard.

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.

Look setSetting("look", { ...look, value })} /> setSetting("look", setting)} />

How this dashboard is drawn. Fluksio is the app's own design, and what a dashboard wears until you choose otherwise; Material lays flat tonal cards on a plain ground; Glass floats translucent tiles over a soft moving one. The widgets are the same whichever you pick — a look changes what they look like and nothing about what they do.

Theme setSetting("theme", { ...theme, value })} /> setSetting("theme", setting)} />

What this dashboard wears wherever it is shown — a screen on a wall has nobody to set the device preference System otherwise follows. Bind a message and a flow drives it instead: a node publishing on a cron is what a schedule looks like here, and the choice above stays the fallback. A palette settles this for itself: its first colour is the ground, so a dashboard that names one is already light or dark and this is left idle.

Palette setSetting("palette", { ...paletteSetting, value }) } /> setSetting("palette", setting)} />

The colours this dashboard is drawn in, in order: the ground, the surface a widget is, the primary, the accent, and the text. Leave the later ones off and they are worked out from the ones you gave — three colours are a whole dashboard. Anything past the five is another colour for a chart to draw a line in. Name none and the dashboard keeps the app's own.

Background setSetting("background", { ...background, value: event.target.value, }) } /> setSetting("background", setting)} />

An image drawn under the widgets, covering the canvas. It takes the place of the ground the Glass look brings with it. Bind a message and a flow decides the picture — one per season, or one per time of day.

Touch
Touch friendly setSetting("touch", { ...touch, value }) } />
setSetting("touch", setting)} />

Bigger controls, and nothing that only happens on hover — for a panel that is touched rather than pointed at. A phone gets this anyway; a wall panel has no way to say so for itself.

Lock
Read-only setSetting("locked", { ...locked, value }) } />
setSetting("locked", setting)} />

Locked, the controls on this dashboard are shown but stop publishing, and the surface says so. It is a read-only surface rather than a permission: what a paired screen may reach is still decided by its own credential.

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. ) }