Build dashboards you can actually look at and press

Widgets bind to a message name and read it live off the socket the editor
already had — lifted out of the flow editor so a dashboard route gets the
same values, which also gives the home page live data for free.

The input widgets close the loop the other way: a slider publishes into
the graph and whatever consumes that message runs. Verified end to end in
the running app — moving a slider set a flow input, and the stat bound to
what the flow computed from it followed.

View mode is plain CSS grid. A wall panel that only displays should not
download the code that lets someone drag things around, and it now does
not. Editing is a widget picker, a per-widget width control and a
settings card fed by the message catalog.

No new dependencies: the slider is a range input, the gauge is an arc,
and the markdown is a five-line subset. Charts are the one widget still
missing — they need a charting library and the chart tokens the design
guidelines reserved — so they are stored and validated but not offered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
2026-08-16 09:33:02 +02:00
co-authored by Claude Fable 5
parent 895bd89b2b
commit be6d4fc13c
15 changed files with 1388 additions and 7 deletions
@@ -0,0 +1,372 @@
import { useQuery } from "@tanstack/react-query"
import { ChevronLeft, ChevronRight, Plus, X } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import type { ApiError, DashboardDef_Output, WidgetDef } from "@/client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
import {
pagesOf,
SectionGrid,
sectionsOf,
widgetStyle,
widgetsOf,
} from "./DashboardView"
import { messageCatalogQueryOptions, useSaveDashboard } from "./queries"
import {
INPUT_WIDGETS,
WIDGET_LABELS,
WIDGET_SIZES,
WidgetBody,
WidgetFrame,
type WidgetKind,
} from "./widgets"
// Charts are stored and validated, but not drawn yet, so they are not offered.
const KINDS = (Object.keys(WIDGET_LABELS) as WidgetKind[]).filter(
(kind) => kind !== "chart",
)
/** How long to sit on edits before saving, so typing is not a save per key. */
const AUTOSAVE_MS = 800
function nextId(dashboard: DashboardDef_Output, type: string): string {
const taken = new Set(
pagesOf(dashboard).flatMap((page) =>
sectionsOf(page).flatMap((section) =>
widgetsOf(section).map((widget) => widget.id),
),
),
)
let candidate = type
for (let i = 2; taken.has(candidate); i++) candidate = `${type}${i}`
return candidate
}
export function DashboardEditor({
dashboard,
pageId,
}: {
dashboard: DashboardDef_Output
pageId?: string
}) {
const [draft, setDraft] = useState<DashboardDef_Output>(dashboard)
const [selected, setSelected] = useState<string | null>(null)
const save = useSaveDashboard(dashboard.name)
const { showErrorToast } = useCustomToast()
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
// The saved version is what the next save is based on; without following it
// the second save of a session is always a conflict.
const version = useRef(dashboard.version)
useEffect(() => {
version.current = dashboard.version
}, [dashboard.version])
const commit = (next: DashboardDef_Output) => {
setDraft(next)
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(() => {
save.mutate(
{ ...next, version: version.current },
{
onSuccess: (saved) => {
version.current = saved.version
},
onError: (error) =>
handleError.call(showErrorToast, error as ApiError),
},
)
}, AUTOSAVE_MS)
}
const pages = pagesOf(draft)
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
if (!page) return null
const section = sectionsOf(page)[0]
if (!section) return null
const widgets = widgetsOf(section)
const updateWidgets = (next: WidgetDef[]) =>
commit({
...draft,
pages: pagesOf(draft).map((candidate) =>
candidate.id !== page.id
? candidate
: {
...candidate,
sections: sectionsOf(candidate).map((existing) =>
existing.id !== section.id
? existing
: { ...existing, widgets: next },
),
},
),
})
const addWidget = (type: WidgetKind) => {
const id = nextId(draft, type)
updateWidgets([
...widgets,
{
id,
type,
title: WIDGET_LABELS[type],
layout: { lg: { x: 0, y: 0, ...WIDGET_SIZES[type] } },
config: {},
},
])
setSelected(id)
}
const patch = (id: string, changes: Partial<WidgetDef>) =>
updateWidgets(
widgets.map((widget) =>
widget.id === id ? { ...widget, ...changes } : widget,
),
)
const resize = (widget: WidgetDef, by: number) => {
const layout = (widget.layout ?? {}) as Record<
string,
{ x?: number; y?: number; w?: number; h?: number }
>
const current = layout.lg ?? { x: 0, y: 0, w: 3, h: 2 }
patch(widget.id, {
layout: {
...layout,
lg: { ...current, w: Math.min(12, Math.max(2, (current.w ?? 3) + by)) },
},
})
}
const active = widgets.find((widget) => widget.id === selected)
return (
<div className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Add</span>
{KINDS.map((kind) => (
<Button
key={kind}
variant="ghost"
size="sm"
className="h-8 text-xs"
data-testid={`add-widget-${kind}`}
onClick={() => addWidget(kind)}
>
<Plus />
{WIDGET_LABELS[kind]}
</Button>
))}
</div>
<SectionGrid
section={section}
renderWidget={(widget) => (
<WidgetFrame
title={widget.title}
className={
widget.id === selected ? "ring-2 ring-primary" : undefined
}
actions={
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Narrower"
onClick={() => resize(widget, -1)}
>
<ChevronLeft />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Wider"
onClick={() => resize(widget, 1)}
>
<ChevronRight />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove widget"
onClick={() =>
updateWidgets(
widgets.filter((other) => other.id !== widget.id),
)
}
>
<X />
</Button>
</div>
}
>
<button
type="button"
className="min-h-0 flex-1 text-left"
onClick={() => setSelected(widget.id)}
>
<WidgetBody widget={widget} />
</button>
</WidgetFrame>
)}
/>
{active ? (
<WidgetSettings
widget={active}
onChange={(changes) => patch(active.id, changes)}
/>
) : (
<p className="text-sm text-muted-foreground">
Add a widget, or pick one to configure it.
</p>
)}
</div>
)
}
/** What one widget shows or does. Fields differ per type; the set is small. */
function WidgetSettings({
widget,
onChange,
}: {
widget: WidgetDef
onChange: (changes: Partial<WidgetDef>) => void
}) {
const { data: catalog } = useQuery(messageCatalogQueryOptions())
const messages = catalog?.data ?? []
const config = (widget.config ?? {}) as Record<string, unknown>
const isInput = INPUT_WIDGETS.has(widget.type)
const numericOnly = widget.type === "gauge" || widget.type === "chart"
const set = (key: string, value: unknown) =>
onChange({ config: { ...config, [key]: value } })
const choices = messages.filter((message) =>
numericOnly ? message.numeric : true,
)
return (
<div
className="grid gap-3 rounded-lg border border-border bg-card p-4 shadow-e1"
data-testid="widget-settings"
>
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{WIDGET_LABELS[widget.type]} settings
</span>
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Title</Label>
<Input
value={widget.title ?? ""}
onChange={(event) => onChange({ title: event.target.value })}
/>
</div>
{widget.type === "markdown" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Text</Label>
<Input
value={String(config.content ?? "")}
placeholder="# Heading"
onChange={(event) => set("content", event.target.value)}
/>
</div>
) : (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">
{isInput ? "Publishes to" : "Shows"}
</Label>
<Select
value={String(config[isInput ? "target" : "message"] ?? "")}
onValueChange={(value) =>
set(isInput ? "target" : "message", value)
}
>
<SelectTrigger data-testid="widget-message">
<SelectValue placeholder="Pick a message" />
</SelectTrigger>
<SelectContent>
{choices.map((message) => (
<SelectItem key={message.name} value={message.name}>
{message.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{(widget.type === "stat" || widget.type === "gauge") && (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Unit</Label>
<Input
value={String(config.unit ?? "")}
placeholder="°C"
onChange={(event) => set("unit", event.target.value)}
/>
</div>
)}
{(widget.type === "gauge" || widget.type === "slider") && (
<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={String(config.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={String(config.max ?? 100)}
onChange={(event) => set("max", Number(event.target.value) || 0)}
/>
</div>
</div>
)}
{widget.type === "button" && (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Sends</Label>
<Input
value={String(config.value ?? "")}
placeholder="true"
onChange={(event) => {
const raw = event.target.value
const asNumber = Number(raw)
set(
"value",
raw === "true" || raw === "false"
? raw === "true"
: raw !== "" && Number.isFinite(asNumber)
? asNumber
: raw,
)
}}
/>
</div>
)}
</div>
)
}
export { widgetStyle }
@@ -0,0 +1,130 @@
import type {
DashboardDef_Output,
PageDef_Output,
SectionDef_Output,
WidgetDef,
} from "@/client"
import { cn } from "@/lib/utils"
import { WidgetBody, WidgetFrame } from "./widgets"
/**
* Columns per breakpoint. A wall panel gets the full twelve, a phone gets
* three, which is what makes a stat tile still readable at arm's length.
*/
const GRID = "grid-cols-3 md:grid-cols-6 lg:grid-cols-12"
/** One grid row, in pixels. Widget heights are multiples of this. */
const ROW = "5rem"
/**
* The generated client marks every list optional, because the server fills
* them in. These three keep that from spreading through the components.
*/
export const pagesOf = (dashboard: DashboardDef_Output) => dashboard.pages ?? []
export const sectionsOf = (page: PageDef_Output) => page.sections ?? []
export const widgetsOf = (section: SectionDef_Output) => section.widgets ?? []
function placement(widget: WidgetDef) {
const layout = (widget.layout ?? {}) as Record<
string,
{ x?: number; y?: number; w?: number; h?: number }
>
return layout.lg ?? layout.md ?? layout.sm ?? {}
}
/**
* A widget's box, as plain CSS grid.
*
* View mode never loads a grid library: a wall panel that only displays
* should not pay for the code that lets someone drag things around.
*/
export function widgetStyle(widget: WidgetDef): React.CSSProperties {
const { w = 3, h = 2 } = placement(widget)
return {
gridColumn: `span ${Math.min(12, Math.max(1, w))}`,
gridRow: `span ${Math.max(1, h)}`,
}
}
export function SectionGrid({
section,
renderWidget,
className,
}: {
section: SectionDef_Output
renderWidget?: (widget: WidgetDef) => React.ReactNode
className?: string
}) {
return (
<section className="grid gap-3">
{section.title ? (
<h2 className="text-sm font-medium text-muted-foreground">
{section.title}
</h2>
) : null}
<div
className={cn("grid gap-3", GRID, className)}
style={{ gridAutoRows: ROW }}
>
{widgetsOf(section).map((widget) => (
<div key={widget.id} style={widgetStyle(widget)} className="min-w-0">
{renderWidget ? (
renderWidget(widget)
) : (
<WidgetFrame title={widget.title}>
<WidgetBody widget={widget} />
</WidgetFrame>
)}
</div>
))}
</div>
</section>
)
}
export function DashboardView({
dashboard,
pageId,
renderWidget,
}: {
dashboard: DashboardDef_Output
pageId?: string
renderWidget?: (widget: WidgetDef) => React.ReactNode
}) {
const pages = pagesOf(dashboard)
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
if (!page) {
return (
<p className="text-sm text-muted-foreground">
This dashboard has no pages yet.
</p>
)
}
const empty = sectionsOf(page).every(
(section) => widgetsOf(section).length === 0,
)
if (empty) {
return (
<p
className="text-sm text-muted-foreground"
data-testid="dashboard-empty"
>
Nothing on this page yet. Edit it to add a widget.
</p>
)
}
return (
<div className="grid gap-6">
{sectionsOf(page).map((section) => (
<SectionGrid
key={section.id}
section={section}
renderWidget={renderWidget}
/>
))}
</div>
)
}
@@ -0,0 +1,56 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import {
type DashboardDef_Input,
DashboardsService,
MessagesService,
} from "@/client"
export const dashboardKeys = {
all: ["dashboards"] as const,
detail: (name: string) => ["dashboards", name] as const,
messages: ["messages"] as const,
history: (message: string) => ["messages", message, "history"] as const,
}
export const dashboardsQueryOptions = () => ({
queryKey: dashboardKeys.all,
queryFn: () => DashboardsService.readDashboards(),
})
export const dashboardQueryOptions = (name: string) => ({
queryKey: dashboardKeys.detail(name),
queryFn: () => DashboardsService.readDashboard({ name }),
})
/** Every message any flow declares — what a widget can be pointed at. */
export const messageCatalogQueryOptions = () => ({
queryKey: dashboardKeys.messages,
queryFn: () => MessagesService.readMessages(),
})
export const messageHistoryQueryOptions = (message: string) => ({
queryKey: dashboardKeys.history(message),
queryFn: () => MessagesService.readMessageHistory({ name: message }),
})
/** Saving a dashboard, carrying the version it was based on. */
export function useSaveDashboard(name: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: DashboardDef_Input) =>
DashboardsService.saveDashboard({ name, requestBody: body }),
onSuccess: (saved) => {
queryClient.setQueryData(dashboardKeys.detail(name), saved)
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
},
})
}
/** What an input widget does: put a value into the graph. */
export function usePublishMessage() {
return useMutation({
mutationFn: ({ name, value }: { name: string; value: unknown }) =>
MessagesService.publishMessage({ name, requestBody: { value } }),
})
}
@@ -0,0 +1,446 @@
import { useState } from "react"
import type { WidgetDef } from "@/client"
import { useLiveValue } from "@/components/Flow/liveStore"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { usePublishMessage } from "./queries"
/** Widget types that put a value into the graph rather than read one. */
export const INPUT_WIDGETS = new Set([
"button",
"switch",
"slider",
"input",
"dropdown",
])
export type WidgetKind = WidgetDef["type"]
export const WIDGET_LABELS: Record<WidgetKind, string> = {
stat: "Value",
gauge: "Gauge",
chart: "Chart",
markdown: "Text",
button: "Button",
switch: "Switch",
slider: "Slider",
input: "Input",
dropdown: "Dropdown",
}
/** 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 },
button: { w: 3, h: 2 },
switch: { w: 3, h: 2 },
slider: { w: 4, h: 2 },
input: { w: 4, h: 2 },
dropdown: { w: 4, h: 2 },
}
function config(widget: WidgetDef): Record<string, unknown> {
return (widget.config ?? {}) as Record<string, unknown>
}
function text(value: unknown, fallback = ""): string {
return value === null || value === undefined ? fallback : String(value)
}
function num(value: unknown, fallback: number): number {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
/** Formats a reading the way a panel across the room should read it. */
function format(value: unknown, precision: number | null): string {
if (value === null || value === undefined) return "—"
if (typeof value === "boolean") return value ? "On" : "Off"
if (typeof value === "number") {
return precision === null ? String(value) : value.toFixed(precision)
}
if (typeof value === "object") return JSON.stringify(value)
return String(value)
}
/**
* The frame every widget sits in.
*
* A card rather than floating chrome: a dashboard is content, and the panels
* that float are the ones that sit over something.
*/
export function WidgetFrame({
title,
children,
actions,
className,
}: {
title?: string
children: React.ReactNode
actions?: React.ReactNode
className?: string
}) {
return (
<div
className={cn(
"flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1",
className,
)}
>
{title || actions ? (
<div className="flex items-start justify-between gap-2">
{title ? (
<span className="truncate text-sm text-muted-foreground">
{title}
</span>
) : (
<span />
)}
{actions}
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col justify-center">
{children}
</div>
</div>
)
}
function Unbound() {
return <p className="text-sm text-muted-foreground">Pick a message.</p>
}
// ---------------------------------------------------------------------------
// Display
// ---------------------------------------------------------------------------
function StatWidget({ widget }: { widget: WidgetDef }) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
if (!message) return <Unbound />
const precision = cfg.precision === undefined ? null : num(cfg.precision, 1)
return (
<div className="flex items-baseline gap-1.5">
<span className="truncate text-3xl tabular-nums">
{format(live?.value, precision)}
</span>
{cfg.unit ? (
<span className="text-base text-muted-foreground">
{text(cfg.unit)}
</span>
) : null}
</div>
)
}
/**
* 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 }: { widget: WidgetDef }) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
if (!message) return <Unbound />
const min = num(cfg.min, 0)
const max = num(cfg.max, 100)
const value = typeof live?.value === "number" ? live.value : null
const fraction =
value === null
? 0
: Math.min(1, Math.max(0, (value - min) / (max - min || 1)))
// A 240° arc, the shape a dial is expected to have.
const radius = 42
const sweep = 240
const start = 150
const point = (angle: number) => {
const radians = (angle * Math.PI) / 180
return [50 + radius * Math.cos(radians), 50 + radius * Math.sin(radians)]
}
const arc = (from: number, to: number) => {
const [x1, y1] = point(from)
const [x2, y2] = point(to)
const large = Math.abs(to - from) > 180 ? 1 : 0
return `M ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2}`
}
return (
<div className="flex min-h-0 flex-1 items-center justify-center">
<svg
viewBox="0 0 100 78"
className="h-full max-h-full w-full"
role="img"
aria-label={`${format(value, 1)} of ${max}`}
>
<path
d={arc(start, start + sweep)}
fill="none"
stroke="var(--muted)"
strokeWidth={9}
strokeLinecap="round"
/>
{fraction > 0 ? (
<path
d={arc(start, start + sweep * fraction)}
fill="none"
stroke="var(--primary)"
strokeWidth={9}
strokeLinecap="round"
/>
) : null}
<text
x={50}
y={54}
textAnchor="middle"
className="fill-foreground text-[13px] tabular-nums"
>
{format(
value,
cfg.precision === undefined ? 1 : num(cfg.precision, 1),
)}
{cfg.unit ? text(cfg.unit) : ""}
</text>
</svg>
</div>
)
}
/**
* A very small markdown subset: headings, bold, code, links, list items.
*
* Enough for the labels and notes a dashboard carries, and not worth a parser.
*/
function MarkdownWidget({ widget }: { widget: WidgetDef }) {
const content = text(config(widget).content)
const lines = content.split("\n")
return (
<div className="grid gap-1 text-sm">
{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] === "#" && "text-lg font-medium",
heading?.[1] === "##" && "font-medium",
heading?.[1] === "###" && "text-muted-foreground",
bullet && "pl-4",
)}
>
{bullet ? "• " : ""}
{body}
</p>
)
})}
</div>
)
}
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
/** Publishing, with the value shown as sent until the engine confirms it. */
function usePublish(widget: WidgetDef) {
const cfg = config(widget)
const target = text(cfg.target)
const publish = usePublishMessage()
const live = useLiveValue(target || undefined)
return {
target,
live,
send: (value: unknown) => {
if (!target) return
publish.mutate({ name: target, value })
},
pending: publish.isPending,
}
}
function ButtonWidget({ widget }: { widget: WidgetDef }) {
const cfg = config(widget)
const { target, send, pending } = usePublish(widget)
if (!target) return <Unbound />
return (
<Button
variant="secondary"
className="w-full"
disabled={pending}
onClick={() => send(cfg.value ?? true)}
>
{text(cfg.label, widget.title || "Send")}
</Button>
)
}
function SwitchWidget({ widget }: { widget: WidgetDef }) {
const { target, live, send } = usePublish(widget)
if (!target) return <Unbound />
return (
<div className="flex items-center justify-between gap-2">
<span className="text-sm">{live?.value === true ? "On" : "Off"}</span>
<Switch
checked={live?.value === true}
aria-label={widget.title || target}
onCheckedChange={(checked) => send(checked)}
/>
</div>
)
}
function SliderWidget({ widget }: { widget: WidgetDef }) {
const cfg = config(widget)
const { target, live, send } = usePublish(widget)
const min = num(cfg.min, 0)
const max = num(cfg.max, 100)
const step = num(cfg.step, 1)
// While dragging, the handle follows the finger rather than the engine.
const [dragging, setDragging] = useState<number | null>(null)
if (!target) return <Unbound />
const current =
dragging ?? (typeof live?.value === "number" ? live.value : min)
return (
<div className="grid gap-2">
<div className="flex items-baseline justify-between">
<span className="text-2xl tabular-nums">{current}</span>
{cfg.unit ? (
<span className="text-sm text-muted-foreground">
{text(cfg.unit)}
</span>
) : null}
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={current}
aria-label={widget.title || target}
className="h-11 w-full accent-[var(--primary)] md:h-8"
onChange={(event) => setDragging(Number(event.target.value))}
// Only the release publishes: dragging would otherwise send a value
// per pixel and flood whatever is listening.
onPointerUp={() => {
if (dragging !== null) send(dragging)
setDragging(null)
}}
onKeyUp={() => {
if (dragging !== null) send(dragging)
setDragging(null)
}}
/>
</div>
)
}
function InputWidget({ widget }: { widget: WidgetDef }) {
const cfg = config(widget)
const { target, live, send } = usePublish(widget)
const [draft, setDraft] = useState<string | null>(null)
if (!target) return <Unbound />
const asNumber = cfg.dtype === "float" || cfg.dtype === "int"
const commit = () => {
if (draft === null) return
send(asNumber ? Number(draft) || 0 : draft)
setDraft(null)
}
return (
<Input
value={draft ?? text(live?.value)}
type={asNumber ? "number" : "text"}
aria-label={widget.title || target}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") commit()
}}
/>
)
}
function DropdownWidget({ widget }: { widget: WidgetDef }) {
const cfg = config(widget)
const { target, live, send } = usePublish(widget)
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
if (!target) return <Unbound />
return (
<div className="grid gap-1.5">
<Label className="sr-only">{widget.title || target}</Label>
<Select
value={text(live?.value)}
onValueChange={(value) => send(asOriginal(value, options))}
>
<SelectTrigger aria-label={widget.title || target}>
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={text(option.value)} value={text(option.value)}>
{option.label ?? text(option.value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
/** Radix hands back a string; the message wants whatever was configured. */
function asOriginal(selected: string, options: { value?: unknown }[]): unknown {
const match = options.find((option) => text(option.value) === selected)
return match ? match.value : selected
}
// ---------------------------------------------------------------------------
const RENDERERS: Partial<
Record<WidgetKind, (props: { widget: WidgetDef }) => React.ReactNode>
> = {
stat: StatWidget,
gauge: GaugeWidget,
markdown: MarkdownWidget,
button: ButtonWidget,
switch: SwitchWidget,
slider: SliderWidget,
input: InputWidget,
dropdown: DropdownWidget,
}
export function WidgetBody({ widget }: { widget: WidgetDef }) {
const Renderer = RENDERERS[widget.type]
if (!Renderer) {
return (
<p className="text-sm text-muted-foreground">
{WIDGET_LABELS[widget.type]} widgets are not drawn yet.
</p>
)
}
return <Renderer widget={widget} />
}
@@ -978,10 +978,6 @@ export function FlowEditor({ flowName }: { flowName: string }) {
useFlowSocket(onAuthFailure)
useEffect(() => {
return () => liveStore.reset()
}, [])
return (
<ReactFlowProvider>
{/*
@@ -6,6 +6,9 @@ import { type LogLine, liveStore } from "./liveStore"
import { flowKeys } from "./queries"
const RECONNECT_MIN = 1000
/** How many components want the socket open. */
let mounted = 0
const RECONNECT_MAX = 30000
type FlowEvent =
@@ -52,6 +55,14 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
const queryClient = useQueryClient()
useEffect(() => {
// The editor and a dashboard can both be mounted; one socket serves both,
// and the second caller just rides along.
mounted += 1
if (mounted > 1) {
return () => {
mounted -= 1
}
}
closed.current = false
const connect = () => {
@@ -126,6 +137,7 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
connect()
return () => {
mounted -= 1
closed.current = true
if (timer.current) clearTimeout(timer.current)
socket.current?.close()
+11 -2
View File
@@ -1,4 +1,11 @@
import { Home, LogOut, Settings, Users, Workflow } from "lucide-react"
import {
Home,
LayoutDashboard,
LogOut,
Settings,
Users,
Workflow,
} from "lucide-react"
import { Logo } from "@/components/Common/Logo"
import {
@@ -12,8 +19,10 @@ import useAuth from "@/hooks/useAuth"
import { type Item, Main } from "./Main"
const baseItems: Item[] = [
{ icon: Home, title: "Dashboard", path: "/" },
// "Home" rather than "Dashboard": dashboards are their own thing now.
{ icon: Home, title: "Home", path: "/" },
{ icon: Workflow, title: "Flows", path: "/flows" },
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
]
export function AppSidebar() {