Files
app/frontend/src/components/Dashboard/panels.tsx
T
stroblme 8f3e229c8f Sandbox website tiles by default; opt into same-origin per widget
A website tile now runs in an opaque origin (allow-scripts, popups, forms).
Its new "Trusted site" setting adds allow-same-origin for sites that need
their own cookies and storage, and a trusted tile pointing at the origin the
app runs on (its own, or the hub's) shows a notice instead of the frame.
2026-10-07 09:31:49 +02:00

1929 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
FieldLabel,
InfoTip,
PANEL_SECTION,
PanelSection,
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,
type ButtonEntry,
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<string, unknown>
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 (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">{label}</Label>
<Select
value={value}
onValueChange={(next) =>
onPick(
next,
choices.find((message) => message.name === next)?.dtype ?? "",
)
}
>
<SelectTrigger data-testid={testId}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{choices.map((message) => (
<SelectItem key={message.name} value={message.name}>
{message.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
/** 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 (
<div className="grid gap-2">
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Of flow</Label>
<Select
value={runs.flow ?? ""}
onValueChange={(flow) => onChange({ ...runs, flow })}
>
<SelectTrigger data-testid="runs-flow">
<SelectValue placeholder="Pick a flow" />
</SelectTrigger>
<SelectContent>
{(flows ?? []).map((row) => (
<SelectItem key={row.flow} value={row.flow}>
{row.flow}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<MessagePicker
kind="chart"
value={runs.metric ?? ""}
label="Draws"
testId="runs-metric"
placeholder="Pick a metric"
filter={(message) =>
(message.dtype === "float" || message.dtype === "int") &&
(!runs.flow || message.name.startsWith(`${runs.flow}.`))
}
onPick={(metric) => onChange({ ...runs, metric })}
/>
<Segmented
value={mode}
options={RUN_PICKS}
label="Which runs"
testId="runs-pick"
onChange={(picked) =>
onChange({
...runs,
group: undefined,
ids: undefined,
latest: picked === "latest" ? (runs.latest ?? 3) : undefined,
})
}
/>
{mode === "latest" && (
<div className="grid gap-1.5">
<Label className="font-normal text-sm">How many</Label>
<Input
type="number"
min={1}
max={MAX_SERIES}
value={runs.latest ?? 3}
aria-label="How many runs"
onChange={(event) =>
onChange({ ...runs, latest: Number(event.target.value) })
}
/>
</div>
)}
{mode === "group" && (
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Sweep</Label>
<Input
value={runs.group ?? ""}
placeholder="A sweep's group id"
onChange={(event) =>
onChange({ ...runs, group: event.target.value })
}
/>
</div>
)}
{mode === "ids" && (
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Runs</Label>
<Input
value={(runs.ids ?? []).join(",")}
placeholder="Run ids, comma separated"
onChange={(event) =>
onChange({
...runs,
ids: event.target.value
.split(",")
.map((one) => one.trim())
.filter(Boolean),
})
}
/>
</div>
)}
<div className="grid gap-1.5">
<Label className="font-normal text-sm">Refresh (seconds)</Label>
<Input
type="number"
min={15}
value={Number(refreshS) || 60}
aria-label="Refresh seconds"
onChange={(event) => onRefresh(Number(event.target.value))}
/>
</div>
</div>
)
}
/** 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.
*
* The field at the top narrows the grid by name, so the glyphs stay findable
* as the map grows past what a popover can show at once.
*/
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)
// Cleared whenever the popover opens or closes: a filter left over from last
// time hides icons that are still there.
const [filter, setFilter] = useState("")
const Current = ICONS[value]
const needle = filter.trim().toLowerCase()
const shown = ICON_NAMES.filter((name) => name.toLowerCase().includes(needle))
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next)
setFilter("")
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
// Sits in a row of fields, so it wears their border and surface
// rather than a button's.
className={cn(
"justify-start border-input bg-transparent font-normal",
className,
)}
aria-label={label}
data-testid={testId}
>
{Current ? <Current /> : null}
<span className={cn("truncate", !value && "text-muted-foreground")}>
{value || placeholder}
</span>
<ChevronDown className="ml-auto opacity-50" />
</Button>
</PopoverTrigger>
{/* Fixed width rather than auto: the grid would otherwise shrink under
the cursor as the filter narrows it. */}
<PopoverContent align="start" className="w-60 p-2">
<Input
value={filter}
placeholder="Filter"
aria-label="Filter icons"
className="mb-2 h-8 text-sm"
onChange={(event) => setFilter(event.target.value)}
/>
<div className="grid grid-cols-6 justify-items-center gap-1">
{shown.map((name) => {
const Glyph = ICONS[name]
return (
<Button
key={name}
variant="ghost"
size="icon-sm"
title={name}
aria-label={name}
aria-pressed={name === value}
className={cn(
name === value && "bg-accent text-accent-foreground",
)}
onClick={() => {
onChange(name)
setOpen(false)
}}
>
<Glyph />
</Button>
)
})}
</div>
{shown.length === 0 ? (
<p className="px-1 text-sm text-muted-foreground">
No icon by that name.
</p>
) : null}
<Button
variant="ghost"
size="sm"
className="mt-1 w-full justify-start text-muted-foreground"
data-testid={testId ? `${testId}-clear` : undefined}
onClick={() => {
onChange("")
setOpen(false)
}}
>
<Ban />
{placeholder}
</Button>
</PopoverContent>
</Popover>
)
}
/** 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<WidgetDef>) => 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<string, unknown>) =>
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 })
// A grid's presses, read raw rather than through `buttonsOf`: that drops the
// blanks, and a row being typed into is blank until the first keystroke. An
// empty row stands in for none, so a fresh grid offers the fields.
const written = (cfg.buttons ?? []) as ButtonEntry[]
const buttons: ButtonEntry[] = written.length ? written : [{}]
const setButtons = (next: ButtonEntry[]) => set({ buttons: 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 (
<SidePanel
open={Boolean(widget)}
label="Widget settings"
testId="widget-panel"
bodyKey={widget.id}
onClose={onClose}
header={
<PanelTitle
value={widget.title ?? ""}
placeholder={WIDGET_LABELS[widget.type]}
label="Widget title"
onConfirm={(title) => onChange({ title })}
/>
}
footer={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onDelete}
data-testid="delete-widget"
>
Remove widget
</Button>
}
>
<div className="grid gap-5 p-4" data-testid="widget-settings">
<div className="grid gap-2">
<span className={PANEL_SECTION}>{WIDGET_LABELS[widget.type]}</span>
{issue ? (
<p className="text-sm text-destructive" data-testid="widget-error">
{issue}
</p>
) : 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. */}
<div className="flex items-center justify-between gap-2 text-sm">
Show title
<Switch
checked={showTitle(widget)}
aria-label="Show title"
data-testid="widget-show-title"
onCheckedChange={(value) => set({ show_title: value })}
/>
</div>
{widget.type === "embed" ? (
<>
<div className="grid gap-1.5">
<Label className="text-sm font-normal" htmlFor="embed-url">
Address
</Label>
<InfoTip label="Address">
Any page that allows being framed. A site that refuses shows
its own refusal in the tile.
</InfoTip>
<Input
id="embed-url"
value={str(cfg.url)}
placeholder="https://embed.ventusky.com/?p=49.41;8.93;9"
onChange={(event) => set({ url: event.target.value })}
/>
</div>
<div className="flex items-center justify-between gap-2 text-sm">
<FieldLabel help="Lets the page keep its own cookies and storage, which many interactive sites need to load. Only for a site you trust; this app's own address is refused.">
Trusted site
</FieldLabel>
<Switch
checked={cfg.trusted === true}
aria-label="Trusted site"
data-testid="embed-trusted"
onCheckedChange={(trusted) => set({ trusted })}
/>
</div>
</>
) : widget.type === "markdown" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Text</Label>
<Input
value={str(cfg.content)}
placeholder="# Heading"
onChange={(event) => set({ content: event.target.value })}
/>
</div>
) : widget.type === "chart" ? (
<div className="grid gap-3">
<Segmented
value={pinnedRuns ? "runs" : querying ? "query" : "live"}
options={CHART_SOURCES}
label="Where the chart's data comes from"
testId="chart-source"
onChange={(source) => set({ source })}
/>
{pinnedRuns ? (
<RunsSourceFields
runs={runsPick}
refreshS={cfg.refresh_s}
onChange={(runs) => set({ runs })}
onRefresh={(refresh_s) => set({ refresh_s })}
/>
) : querying ? (
<div className="grid gap-2">
<MessagePicker
kind="chart"
value={str(cfg.request)}
label="Asks"
testId="widget-request"
filter={(message) =>
message.dtype === "record" && message.writable !== false
}
onPick={(request, request_dtype) =>
set({ request, request_dtype })
}
/>
<MessagePicker
kind="chart"
value={str(cfg.message)}
label="Draws"
testId="widget-message"
filter={(message) => message.dtype === "series"}
onPick={(message, dtype) => set({ message, dtype })}
/>
</div>
) : (
<div className="grid gap-2">
{series.map((entry, index) => (
<div
// Position is the only identity a series row has.
key={`series-${index}`}
className="flex items-end gap-1.5"
>
<div className="min-w-0 flex-1">
<MessagePicker
kind="chart"
value={entry.message ?? ""}
label={index === 0 ? "Draws" : ""}
testId={index === 0 ? "widget-message" : undefined}
onPick={(message, dtype) =>
setSeries(
series.map((other, at) =>
at === index
? { ...other, message, dtype }
: other,
),
)
}
/>
</div>
<Input
className="w-28"
value={entry.label ?? ""}
placeholder="Label"
aria-label="Series label"
onChange={(event) =>
setSeries(
series.map((other, at) =>
at === index
? { ...other, label: event.target.value }
: other,
),
)
}
/>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove series"
onClick={() =>
setSeries(series.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
))}
{series.length < MAX_SERIES ? (
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setSeries([...series, {}])}
data-testid="add-series"
>
<Plus />
Add series
</Button>
) : null}
</div>
)}
</div>
) : // 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 : (
<MessagePicker
kind={widget.type}
value={str(cfg[isInput ? "target" : "message"])}
label={isInput ? "Publishes to" : "Shows"}
testId="widget-message"
// A colour widget may bind either shape, and the format is what
// decides which of the two — so it filters rather than the type.
filter={
widget.type === "color"
? (message) =>
message.dtype === COLOR_DTYPES[colorFormatOf(widget)] &&
message.writable !== false
: undefined
}
onPick={(message, dtype) =>
set({ [isInput ? "target" : "message"]: message, dtype })
}
/>
)}
</div>
{widget.type === "bar" ? (
<div className="grid gap-3">
{rows.map((row, index) => (
<div
// Position is the only identity a row has, as with series.
key={`row-${index}`}
className="grid gap-1.5"
>
<div className="flex items-end gap-1.5">
<div className="min-w-0 flex-1">
<MessagePicker
kind="bar"
value={row.message ?? ""}
label={index === 0 ? "Draws" : ""}
testId={index === 0 ? "widget-message" : undefined}
onPick={(message, dtype) =>
setRows(
rows.map((other, at) =>
at === index ? { ...other, message, dtype } : other,
),
)
}
/>
</div>
<Input
className="w-28"
value={row.label ?? ""}
placeholder="Label"
aria-label="Row label"
onChange={(event) =>
setRows(
rows.map((other, at) =>
at === index
? { ...other, label: event.target.value }
: other,
),
)
}
/>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove row"
onClick={() =>
setRows(rows.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
{/* 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. */}
<div className="flex gap-1.5">
<Input
type="number"
className="w-20"
placeholder="Min"
aria-label="Row minimum"
value={row.min === undefined ? "" : String(row.min)}
onChange={(event) =>
setRows(
rows.map((other, at) =>
at === index
? {
...other,
min: numberOrNone(event.target.value),
}
: other,
),
)
}
/>
<Input
type="number"
className="w-20"
placeholder="Max"
aria-label="Row maximum"
value={row.max === undefined ? "" : String(row.max)}
onChange={(event) =>
setRows(
rows.map((other, at) =>
at === index
? {
...other,
max: numberOrNone(event.target.value),
}
: other,
),
)
}
/>
<Input
className="min-w-0 flex-1"
placeholder="Unit"
aria-label="Row unit"
value={row.unit ?? ""}
onChange={(event) =>
setRows(
rows.map((other, at) =>
at === index
? {
...other,
unit: event.target.value || undefined,
}
: other,
),
)
}
/>
</div>
</div>
))}
{rows.length < MAX_ROWS ? (
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setRows([...rows, {}])}
data-testid="add-row"
>
<Plus />
Add row
</Button>
) : null}
</div>
) : null}
{widget.type === "chart" && !querying ? (
<div className="grid gap-1.5">
<FieldLabel help="How much past the engine keeps for these messages.">
Points kept
</FieldLabel>
<Input
type="number"
value={str(
(cfg.history as { points?: number } | undefined)?.points ?? 300,
)}
onChange={(event) =>
set({ history: { points: Number(event.target.value) || 0 } })
}
/>
</div>
) : null}
{widget.type === "chart" && querying ? (
<>
<div className="grid gap-1.5">
<FieldLabel
help={`Leave empty to follow the window: ${paced} seconds at this range, since nothing changes until the bucket closes. A slower value is kept; a faster one only asks for the same picture twice.`}
>
Refresh, seconds
</FieldLabel>
<Input
type="number"
min={paced}
placeholder={str(paced)}
value={str(cfg.refresh_s ?? "")}
onChange={(event) =>
set({
refresh_s:
event.target.value === ""
? undefined
: Number(event.target.value),
})
}
/>
</div>
<div className="grid gap-1.5">
<FieldLabel help="A viewer can pick another on the widget itself.">
Range shown first
</FieldLabel>
<Select
value={str(cfg.range_s ?? DEFAULT_RANGE.hours * 3600)}
onValueChange={(value) => set({ range_s: Number(value) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RANGES.map((range) => (
<SelectItem
key={range.label}
value={String(range.hours * 3600)}
>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
) : null}
{widget.type === "agenda" || widget.type === "forecast" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Items shown</Label>
<Input
type="number"
min={1}
value={str(cfg.count ?? 5)}
onChange={(event) =>
set({ count: Number(event.target.value) || 0 })
}
/>
</div>
) : null}
{widget.type === "chart" ? (
<div className="flex gap-2">
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Y minimum</Label>
<Input
type="number"
value={str(cfg.y_min ?? "")}
placeholder="auto"
onChange={(event) =>
set({
y_min:
event.target.value === ""
? undefined
: Number(event.target.value),
})
}
/>
</div>
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Y maximum</Label>
<Input
type="number"
value={str(cfg.y_max ?? "")}
placeholder="auto"
onChange={(event) =>
set({
y_max:
event.target.value === ""
? undefined
: Number(event.target.value),
})
}
/>
</div>
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Y axis title</Label>
<Input
value={str(cfg.y_label)}
placeholder="kW"
onChange={(event) => set({ y_label: event.target.value })}
/>
</div>
</div>
) : null}
{widget.type === "chart" ? (
<div className="grid gap-1.5">
<div className="flex items-center justify-between gap-2 text-sm">
<FieldLabel help="Curves the lines between readings. It draws them softer; it does not change what was measured.">
Smoothing
</FieldLabel>
<Switch
checked={Boolean(cfg.smooth)}
aria-label="Smoothing"
data-testid="chart-smooth"
onCheckedChange={(smooth) => set({ smooth })}
/>
</div>
</div>
) : null}
{widget.type === "stat" ||
widget.type === "gauge" ||
widget.type === "chart" ||
widget.type === "bar" ||
widget.type === "slider" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Unit</Label>
<Input
value={str(cfg.unit)}
placeholder="°C"
onChange={(event) => set({ unit: event.target.value })}
/>
</div>
) : null}
{widget.type === "gauge" ||
widget.type === "slider" ||
widget.type === "bar" ? (
<div className="flex gap-2">
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Minimum</Label>
<Input
type="number"
value={str(cfg.min ?? 0)}
onChange={(event) =>
set({ min: Number(event.target.value) || 0 })
}
/>
</div>
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Maximum</Label>
<Input
type="number"
value={str(cfg.max ?? 100)}
onChange={(event) =>
set({ max: Number(event.target.value) || 0 })
}
/>
</div>
{widget.type === "slider" ? (
<div className="grid flex-1 gap-1.5">
<Label className="text-sm font-normal">Step</Label>
<Input
type="number"
min={0}
step="any"
value={str(cfg.step ?? 1)}
onChange={(event) =>
set({ step: Number(event.target.value) || 1 })
}
/>
</div>
) : null}
</div>
) : null}
{widget.type === "slider" ? (
<div className="grid gap-1.5">
<FieldLabel help="A fader takes the tile's height, so give it a tall one. Either way the scale is dropped, since a column has no room for the labels.">
Drawn as
</FieldLabel>
<Segmented
value={str(cfg.orientation) || "horizontal"}
options={[
["horizontal", "Row"],
["vertical", "Fader"],
]}
label="How this slider is drawn"
testId="widget-orientation"
onChange={(orientation) => set({ orientation })}
/>
</div>
) : null}
{widget.type === "button" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Sends</Label>
<Input
value={str(cfg.value)}
placeholder="true"
onChange={(event) => set({ value: coerce(event.target.value) })}
/>
</div>
) : null}
{widget.type === "buttons" ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Buttons</span>
<InfoTip label="Buttons">
One press per row, all publishing to the message above. The grid
fits as many columns as the tile is wide; a row with no value
sends its own label.
</InfoTip>
{buttons.map((button, index) => (
<div
// Position is the only identity a press has.
key={`press-${index}`}
className="flex items-end gap-1.5"
>
<Input
className="min-w-0 flex-1"
value={str(button.label)}
placeholder="Label"
aria-label="Button label"
onChange={(event) =>
setButtons(
buttons.map((other, at) =>
at === index
? { ...other, label: event.target.value }
: other,
),
)
}
/>
<Input
className="w-28"
value={str(button.value)}
placeholder="Value"
aria-label="Button value"
onChange={(event) =>
setButtons(
buttons.map((other, at) =>
at === index
? { ...other, value: coerce(event.target.value) }
: other,
),
)
}
/>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove button"
onClick={() =>
setButtons(buttons.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setButtons([...buttons, {}])}
data-testid="add-button"
>
<Plus />
Add button
</Button>
</div>
) : null}
{widget.type === "dropdown" ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Options</span>
{options.map((option, index) => (
<div
// Position is the only identity an option row has.
key={`option-${index}`}
className="flex items-end gap-1.5"
>
<Input
className="min-w-0 flex-1"
value={str(option.label)}
placeholder="Label"
aria-label="Option label"
onChange={(event) =>
setOptions(
options.map((other, at) =>
at === index
? { ...other, label: event.target.value }
: other,
),
)
}
/>
<Input
className="w-28"
value={str(option.value)}
placeholder="Value"
aria-label="Option value"
onChange={(event) =>
setOptions(
options.map((other, at) =>
at === index
? { ...other, value: coerce(event.target.value) }
: other,
),
)
}
/>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove option"
onClick={() =>
setOptions(options.filter((_, at) => at !== index))
}
>
<X />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setOptions([...options, {}])}
data-testid="add-option"
>
<Plus />
Add option
</Button>
</div>
) : null}
{widget.type === "icon" ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Mapping</span>
<InfoTip label="Mapping">
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.
</InfoTip>
{rules.map((rule, index) => (
<div
// Position is the only identity a rule row has.
key={`rule-${index}`}
className="flex flex-wrap items-end gap-1.5"
>
<Input
className="w-20"
value={str(rule.at)}
placeholder="Value"
aria-label="Rule value"
onChange={(event) =>
setRules(
rules.map((other, at) =>
at === index
? { ...other, at: coerce(event.target.value) }
: other,
),
)
}
/>
<IconPicker
value={rule.icon ?? ""}
placeholder="No icon"
label="Rule icon"
className="min-w-0 flex-1"
onChange={(icon) =>
setRules(
rules.map((other, at) =>
at === index ? { ...other, icon } : other,
),
)
}
/>
<Select
value={rule.color ?? "default"}
onValueChange={(color) =>
setRules(
rules.map((other, at) =>
at === index ? { ...other, color } : other,
),
)
}
>
<SelectTrigger className="w-28" aria-label="Rule colour">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(ICON_COLORS).map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove rule"
onClick={() =>
setRules(rules.filter((_, at) => at !== index))
}
>
<X />
</Button>
<Input
className="basis-full"
value={rule.label ?? ""}
placeholder="Caption under the glyph"
aria-label="Rule label"
onChange={(event) =>
setRules(
rules.map((other, at) =>
at === index
? { ...other, label: event.target.value }
: other,
),
)
}
/>
</div>
))}
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
onClick={() => setRules([...rules, {}])}
data-testid="add-icon-rule"
>
<Plus />
Add value
</Button>
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Otherwise</Label>
<IconPicker
value={str(cfg.icon)}
placeholder="Nothing"
label="Icon when no rule matches"
className="w-full"
onChange={(icon) => set({ icon })}
/>
</div>
</div>
) : null}
{widget.type === "color" ? (
<div className="grid gap-1.5">
<FieldLabel
help={
<>
HSV is{" "}
<span className="font-mono">[h 0-360, s 0-100, v 0-100]</span>
, RGB <span className="font-mono">[r, g, b]</span> 0-255, Hex{" "}
<span className="font-mono">"#rrggbb"</span>.
</>
}
>
Sends
</FieldLabel>
<Segmented
value={colorFormatOf(widget)}
options={COLOR_FORMATS}
label="What this wheel publishes"
testId="widget-format"
onChange={(format) =>
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 }),
})
}
/>
</div>
) : null}
{widget.type === "media" ? (
<div className="grid gap-3">
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Fills the tile</Label>
<Segmented
value={str(cfg.fit) || "cover"}
options={[
["cover", "Crop"],
["contain", "Fit"],
]}
label="How the picture fills its tile"
testId="widget-fit"
onChange={(fit) => set({ fit })}
/>
</div>
<div className="grid gap-1.5">
<FieldLabel
htmlFor="media-stream"
help="A camera's own stream, played straight from it. Messages carry one frame at a time, which suits a glance every few seconds rather than live video."
>
Live stream
</FieldLabel>
<Input
id="media-stream"
value={str(cfg.stream_url)}
placeholder="https://camera.local/stream.mjpg"
onChange={(event) => set({ stream_url: event.target.value })}
/>
</div>
<div className="flex items-center justify-between gap-2 text-sm">
<FieldLabel help="A browser only plays sound by itself once someone has touched the page, so a screen nobody has tapped stays silent.">
Play as it arrives
</FieldLabel>
<Switch
checked={Boolean(cfg.autoplay)}
aria-label="Play as it arrives"
data-testid="widget-autoplay"
onCheckedChange={(autoplay) => set({ autoplay })}
/>
</div>
</div>
) : null}
{widget.type === "player" ? (
<div className="grid gap-1.5">
<MessagePicker
kind="player"
value={str(cfg.target)}
label="Publishes to"
testId="widget-target"
// A player is the one widget with two bindings, so its target
// cannot come off the type: it sends words — `toggle`, `next`,
// `seek:90` — while the reading it draws is a record.
filter={(message) =>
message.dtype === "str" && message.writable !== false
}
onPick={(target) => set({ target })}
/>
<p className="text-xs text-muted-foreground">
Transport commands go here as words:{" "}
<span className="font-mono">toggle</span>,{" "}
<span className="font-mono">next</span>,{" "}
<span className="font-mono">prev</span> and{" "}
<span className="font-mono">seek:&lt;seconds&gt;</span>.
</p>
</div>
) : null}
{widget.type === "switch" || widget.type === "dropdown" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Style</Label>
{widget.type === "switch" ? (
<Segmented
value={str(cfg.style) || "track"}
options={[
["track", "Track"],
["button", "Button"],
]}
label="How this control is drawn"
testId="widget-style"
onChange={(style) => set({ style })}
/>
) : (
<Segmented
value={str(cfg.style) || "list"}
options={[
["list", "List"],
["segmented", "Segmented"],
]}
label="How this control is drawn"
testId="widget-style"
onChange={(style) => set({ style })}
/>
)}
</div>
) : null}
</div>
</SidePanel>
)
}
/** 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 (
<div className="grid gap-1.5">
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1">
<MessagePicker
value={str(setting.message)}
label="Driven by"
testId={`dashboard-${name}-message`}
placeholder={`Not bound, always ${valueLabel(name, setting.value)}`}
filter={(message) => message.dtype === want}
onPick={(message, dtype) =>
onChange({ ...setting, message, dtype })
}
/>
</div>
{setting.message ? (
<Button
variant="ghost"
size="icon"
aria-label={`Stop driving ${name}`}
data-testid={`dashboard-${name}-unbind`}
onClick={() => onChange({ value: setting.value })}
>
<X />
</Button>
) : null}
</div>
{issue ? (
<p className="text-sm text-destructive" role="alert">
{issue}
</p>
) : null}
</div>
)
}
/**
* 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 (
<div className="grid gap-2">
<Input
placeholder="Paste a coolors.co link, or hex colours"
aria-label="Palette"
data-testid="dashboard-palette-input"
value={draft}
onChange={(event) => write(event.target.value)}
/>
{palette.length === 0 ? (
<p className="text-sm text-muted-foreground">
No palette. This dashboard keeps the app's own colours.
</p>
) : (
<div
className="flex flex-wrap items-end gap-2"
data-testid="dashboard-palette"
>
{palette.map((hex, index) => (
<span
// Position is the role, so it is also the identity: the same
// colour twice is two different jobs.
key={`${hex}-${index}`}
className="grid justify-items-center gap-1"
>
<span
className="size-8 rounded-full border border-border"
style={{ background: hex }}
aria-label={`${roleLabel(index)} ${hex}`}
role="img"
/>
<span className="text-xs text-muted-foreground">
{roleLabel(index)}
</span>
</span>
))}
{/* The roles are positional, and a palette rarely arrives in the
order a dashboard wants them. Turning it is quicker than
retyping five colours. */}
<Button
variant="ghost"
size="icon"
aria-label="Rotate palette"
data-testid="dashboard-palette-rotate"
disabled={palette.length < 2}
onClick={() => {
const next = [...palette.slice(1), palette[0]]
setDraft(next.join(" "))
onChange(next)
}}
>
<RotateCw />
</Button>
</div>
)}
</div>
)
}
/** 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 === "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<Dashboard>) => 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 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 (
<>
<SidePanel
open={open}
label="Dashboard settings"
testId="dashboard-panel"
bodyKey={dashboard.name}
onClose={onClose}
header={
<PanelTitle
value={dashboard.title ?? ""}
placeholder={dashboard.name}
label="Dashboard title"
onConfirm={(title) => onChange({ title })}
/>
}
footer={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setConfirmOpen(true)}
data-testid="delete-dashboard"
>
Delete dashboard
</Button>
}
>
<div className="grid gap-5 p-4">
<PanelSection
title="Rail icon"
help="Drawn on the rail when a panel carries more than one dashboard."
>
<IconPicker
value={str(dashboard.icon)}
placeholder="Two letters of the title"
label="Rail icon"
testId="dashboard-icon"
className="w-full"
onChange={(icon) => onChange({ icon })}
/>
</PanelSection>
<PanelSection
title="Grid"
help="How many columns wide the layout is. Match it to the panel this will hang on."
>
<Select
value={String(columnsOf(dashboard))}
onValueChange={(value) => onChange({ columns: Number(value) })}
>
<SelectTrigger data-testid="dashboard-columns">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLUMN_CHOICES.map((count) => (
<SelectItem key={count} value={String(count)}>
{count} columns
</SelectItem>
))}
</SelectContent>
</Select>
</PanelSection>
<PanelSection
title="Canvas"
help="The size of the panel this is drawn for, in pixels. Editing and viewing both scale it to fit, so the arrangement looks the same everywhere."
>
<Select
value={
CANVAS_PRESETS.some(
(preset) => sizeKey(preset) === sizeKey(canvas),
)
? sizeKey(canvas)
: ""
}
onValueChange={(value) => {
const preset = CANVAS_PRESETS.find(
(candidate) => sizeKey(candidate) === value,
)
if (preset)
onChange({
canvas_width: preset.width,
canvas_height: preset.height,
})
}}
>
<SelectTrigger data-testid="dashboard-canvas-size">
<SelectValue placeholder="Custom" />
</SelectTrigger>
<SelectContent>
{CANVAS_PRESETS.map((preset) => (
<SelectItem key={sizeKey(preset)} value={sizeKey(preset)}>
{preset.label} — {preset.width}×{preset.height}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<Input
type="number"
aria-label="Canvas width"
data-testid="canvas-width"
value={canvas.width}
onChange={(event) =>
onChange({ canvas_width: Number(event.target.value) || 0 })
}
/>
<Input
type="number"
aria-label="Canvas height"
data-testid="canvas-height"
value={canvas.height}
onChange={(event) =>
onChange({ canvas_height: Number(event.target.value) || 0 })
}
/>
</div>
</PanelSection>
<PanelSection
title="Look"
help="How the dashboard is drawn. Fluksio is the app's own design; Material lays flat tonal cards on a plain ground; Glass floats translucent tiles over a moving one. A look changes how widgets look, never what they do."
>
<Segmented
value={lookOf(look.value)}
options={LOOK_CHOICES}
label="Dashboard look"
testId="dashboard-look"
onChange={(value) => setSetting("look", { ...look, value })}
/>
<SettingBinding
name="look"
setting={look}
onChange={(setting) => setSetting("look", setting)}
/>
</PanelSection>
<PanelSection
title="Theme"
help="Light or dark wherever this is shown. System follows the device, which a wall screen has nobody to set. Bind a message to let a flow drive it on a schedule; this choice stays the fallback. A palette overrides it, since its first colour is the ground."
>
<Segmented
value={str(theme.value) || "system"}
options={THEME_CHOICES}
label="Dashboard theme"
testId="dashboard-theme"
onChange={(value) => setSetting("theme", { ...theme, value })}
/>
<SettingBinding
name="theme"
setting={theme}
onChange={(setting) => setSetting("theme", setting)}
/>
</PanelSection>
<PanelSection
title="Palette"
help="Colours in order: ground, widget surface, primary, accent, text. Leave the later ones off and they are derived, so three is enough for a whole dashboard. A sixth and beyond are chart line colours. Name none to keep the app's own."
>
<PalettePicker
palette={palette}
onChange={(value: string[]) =>
setSetting("palette", { ...paletteSetting, value })
}
/>
<SettingBinding
name="palette"
setting={paletteSetting}
onChange={(setting) => setSetting("palette", setting)}
/>
</PanelSection>
<PanelSection
title="Background"
help="An image drawn under the widgets, covering the canvas. It replaces the ground the Glass look brings. Bind a message to let a flow pick the picture."
>
<Input
type="url"
placeholder="https://…"
aria-label="Background image"
data-testid="dashboard-background"
value={str(background.value)}
onChange={(event) =>
setSetting("background", {
...background,
value: event.target.value,
})
}
/>
<SettingBinding
name="background"
setting={background}
onChange={(setting) => setSetting("background", setting)}
/>
</PanelSection>
<PanelSection
title="Lock"
help="Locked, the controls are still shown but stop publishing, and the surface says so. This is a read-only surface, not a permission: what a paired screen may reach is decided by its own credential."
>
<div className="flex items-center justify-between gap-2 text-sm">
Read-only
<Switch
checked={locked.value === true}
aria-label="Read-only"
data-testid="dashboard-lock"
onCheckedChange={(value) =>
setSetting("locked", { ...locked, value })
}
/>
</div>
<SettingBinding
name="locked"
setting={locked}
onChange={(setting) => setSetting("locked", setting)}
/>
</PanelSection>
<PanelSection
title="Contents"
help="What this dashboard is called in the API, in a panel's list and in a link to it."
>
<p className="text-sm text-muted-foreground">
<span className="font-mono">{dashboard.name}</span>,{" "}
{widgetCount === 0
? "nothing on it yet"
: `${widgetCount} widget${widgetCount === 1 ? "" : "s"}`}
</p>
</PanelSection>
</div>
</SidePanel>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Delete {dashboard.title || dashboard.name}?
</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
Keep it
</Button>
<Button
variant="destructive"
onClick={() => {
setConfirmOpen(false)
onDelete()
}}
data-testid="confirm-delete-dashboard"
>
Delete dashboard
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}