dashboard: charts that query

A chart can now ask for what it draws instead of reading the ring the
engine keeps. It publishes a request — the window and the resolution —
exactly as a slider publishes a value, and draws the series a flow
answers with. What serves the request is the flow's business, so the
widget never learns which database was behind it.

The answer says what it was computed for and one computed for another
window is ignored, so two charts on one node cost a duplicate query
rather than overwriting each other's picture. Identical requests still
in flight are asked once per tab, and the refresh has a floor under it.

The panel gains the presentation the document could already hold: the
per-series label, a unit, and a y axis that can be pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 15:07:18 +02:00
co-authored by Claude Opus 5
parent 413501c6ce
commit e19776aed1
2 changed files with 437 additions and 53 deletions
+261 -49
View File
@@ -3,6 +3,7 @@ import { Plus, X } from "lucide-react"
import { useState } from "react"
import type { MessageInfo, WidgetDef } from "@/client"
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
import {
PANEL_SECTION,
PanelTitle,
@@ -26,7 +27,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { MAX_SERIES } from "./ChartWidget"
import { cn } from "@/lib/utils"
import { MAX_SERIES, MIN_REFRESH_S } from "./ChartWidget"
import {
CANVAS_PRESETS,
COLUMN_CHOICES,
@@ -67,16 +69,21 @@ function MessagePicker({
value,
label,
testId,
filter,
onPick,
}: {
kind: WidgetKind
value: string
label: string
testId?: string
/** What this slot takes, when the widget's own type does not decide it —
* a querying chart asks with one shape and draws another. */
filter?: (message: MessageInfo) => boolean
onPick: (message: string, dtype: string) => void
}) {
const { data } = useQuery(messageCatalogQueryOptions())
const choices = choicesFor(kind, data?.data ?? [])
const catalog = data?.data ?? []
const choices = filter ? catalog.filter(filter) : choicesFor(kind, catalog)
return (
<div className="grid gap-1.5">
@@ -105,6 +112,50 @@ function MessagePicker({
)
}
/**
* Where a chart's lines come from: what the engine kept, or what it asks for.
*
* The one segmented shape — a single border pill, transparent segments,
* bg-accent on the selected one (root DESIGN-GUIDELINES.md).
*/
function ModePicker({
value,
onChange,
}: {
value: "live" | "query"
onChange: (mode: "live" | "query") => void
}) {
return (
<fieldset
data-testid="chart-source"
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
>
<legend className="sr-only">Where the chart's data comes from</legend>
{(
[
["live", "Live"],
["query", "Query"],
] as const
).map(([mode, label]) => (
<button
key={mode}
type="button"
aria-pressed={value === mode}
onClick={() => onChange(mode)}
className={cn(
"rounded-full px-2.5 py-1 text-xs transition-colors",
value === mode
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{label}
</button>
))}
</fieldset>
)
}
/**
* What one widget shows or does.
*
@@ -132,6 +183,7 @@ export function WidgetPanel({
const series = seriesOf(widget)
const setSeries = (next: Series[]) => set({ series: next })
const querying = cfg.source === "query"
return (
<SidePanel
@@ -179,53 +231,101 @@ export function WidgetPanel({
/>
</div>
) : widget.type === "chart" ? (
<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>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove series"
onClick={() =>
setSeries(series.filter((_, at) => at !== index))
<div className="grid gap-3">
<ModePicker
value={querying ? "query" : "live"}
onChange={(source) => set({ source })}
/>
{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
}
>
<X />
</Button>
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>
))}
{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 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>
) : (
<MessagePicker
@@ -240,7 +340,7 @@ export function WidgetPanel({
)}
</div>
{widget.type === "chart" ? (
{widget.type === "chart" && !querying ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Points kept</Label>
<Input
@@ -258,7 +358,105 @@ export function WidgetPanel({
</div>
) : null}
{widget.type === "stat" || widget.type === "gauge" ? (
{widget.type === "chart" && querying ? (
<>
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Refresh, seconds</Label>
<Input
type="number"
min={MIN_REFRESH_S}
value={str(cfg.refresh_s ?? 60)}
onChange={(event) =>
set({ refresh_s: Number(event.target.value) || 0 })
}
/>
<p className="text-xs text-muted-foreground">
How often it asks again. {MIN_REFRESH_S} seconds is the floor —
someone has to run the query.
</p>
</div>
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Range shown first</Label>
<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>
<p className="text-xs text-muted-foreground">
A viewer can pick another on the widget itself.
</p>
</div>
</>
) : null}
{widget.type === "agenda" ? (
<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>
) : null}
{widget.type === "stat" ||
widget.type === "gauge" ||
widget.type === "chart" ||
widget.type === "slider" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Unit</Label>
<Input
@@ -291,6 +489,20 @@ export function WidgetPanel({
}
/>
</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}