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
@@ -1,10 +1,16 @@
import { useQueries } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import type { HistoryPoint } from "@/client"
import {
DEFAULT_RANGE,
RANGES,
type Range,
RangePicker,
} from "@/components/Common/RangePicker"
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
import { useLiveValue } from "@/components/Flow/liveStore"
import { messageHistoryQueryOptions } from "./queries"
import { messageHistoryQueryOptions, usePublishMessage } from "./queries"
import type { Series, WidgetProps } from "./widgets"
// The five-series ceiling is the chart host's, and the panel reads it here.
@@ -13,12 +19,77 @@ export { MAX_SERIES }
/** The window a chart draws, unless it asks for more. */
const DEFAULT_POINTS = 300
/**
* How often a chart may ask, at the fastest.
*
* A refresh interval is a query someone else has to run, so the panel is not
* allowed to set it to nothing.
*/
export const MIN_REFRESH_S = 5
/** How long an unanswered request blocks an identical one. */
const INFLIGHT_MS = 15_000
/**
* Requests waiting for an answer, across every chart in this tab.
*
* Two charts on the same node, or two panels of one dashboard, would otherwise
* ask the same question at the same moment and make the flow run twice.
*
* ponytail: per-tab. Two browsers still duplicate a query — an `interval` on
* the request port is what stops that, and it belongs to the flow that serves
* it rather than to the widget asking.
*/
const inflight = new Map<string, number>()
/** What a data flow answers with: the lines, and what they were computed for. */
type SeriesPayload = {
range_s?: number
interval_s?: number
lines: { label: string; points: [number, number][] }[]
}
function asPayload(value: unknown): SeriesPayload | null {
const payload = value as SeriesPayload | null
return payload && Array.isArray(payload.lines) ? payload : null
}
const config = (widget: WidgetProps["widget"]) =>
(widget.config ?? {}) as Record<string, unknown>
/** The unit and fixed axis a chart is drawn with, in either mode. */
function presentation(cfg: Record<string, unknown>) {
const low = Number(cfg.y_min)
const high = Number(cfg.y_max)
return {
unit: cfg.unit ? String(cfg.unit) : undefined,
yRange:
Number.isFinite(low) && Number.isFinite(high) && low < high
? ([low, high] as [number, number])
: undefined,
}
}
/** The points a chart actually plots: the stored past, then the live tail. */
function merge(fetched: HistoryPoint[], tail: HistoryPoint[], cap: number) {
const since = fetched[fetched.length - 1]?.ts ?? 0
return [...fetched, ...tail.filter((point) => point.ts > since)].slice(-cap)
}
/**
* A chart, drawn from whichever source it was pointed at.
*
* The two modes are separate components rather than branches: each calls a
* different set of hooks, and React counts them.
*/
export function ChartWidget(props: WidgetProps) {
return config(props.widget).source === "query" ? (
<QueryChart {...props} />
) : (
<LiveChart {...props} />
)
}
/**
* Several messages over time, drawn on one axis.
*
@@ -27,7 +98,7 @@ function merge(fetched: HistoryPoint[], tail: HistoryPoint[], cap: number) {
* value that arrives. Its own legend doubles as the hover readout, so the
* cursor tells you what each line was worth at that moment.
*/
export function ChartWidget({ widget }: WidgetProps) {
function LiveChart({ widget }: WidgetProps) {
// Read inline rather than through `widgets.tsx`: that module renders this
// one, and a runtime import back would close the circle.
const series = (((widget.config ?? {}) as { series?: Series[] }).series ?? [])
@@ -101,5 +172,106 @@ export function ChartWidget({ widget }: WidgetProps) {
return <p className="text-sm text-muted-foreground">Pick a message.</p>
}
return <UplotChart labels={labels} plots={plots} />
return (
<UplotChart
labels={labels}
plots={plots}
{...presentation(widget.config as Record<string, unknown>)}
/>
)
}
/**
* A chart that asks for what it draws.
*
* It publishes a request — the window and the resolution it wants — exactly as
* a slider publishes a value, and draws the `series` a flow answers with. What
* serves that request is the flow's business: a database node with a function
* either side of it, and the widget never learns which database it was.
*
* The answer says what it was computed for, and one computed for a different
* window is ignored rather than drawn. Two charts on one node therefore cost a
* duplicate query instead of overwriting each other's picture.
*/
function QueryChart({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const request = String(cfg.request ?? "")
const message = String(cfg.message ?? "")
const refreshS = Math.max(MIN_REFRESH_S, Number(cfg.refresh_s) || 60)
const [range, setRange] = useState<Range>(
() =>
RANGES.find((r) => r.hours * 3600 === Number(cfg.range_s)) ??
DEFAULT_RANGE,
)
const rangeS = range.hours * 3600
const intervalS = range.bucketS
const publish = usePublishMessage()
const live = useLiveValue(message || undefined)
const [answer, setAnswer] = useState<SeriesPayload | null>(null)
// The request is a value like any other, so it goes out the way a control's
// does — and the engine type-checks it against the port that declared it.
const ask = useRef<() => void>(() => {})
ask.current = () => {
if (!request) return
const key = `${request}|${rangeS}|${intervalS}`
const since = inflight.get(key)
if (since !== undefined && Date.now() - since < INFLIGHT_MS) return
inflight.set(key, Date.now())
publish.mutate({
name: request,
value: { range_s: rangeS, interval_s: intervalS },
dashboard,
widget: widget.id,
label: widget.title || widget.id,
kind: widget.type,
})
}
// Ask now, then keep asking. A new window tears the timer down and asks
// again at once rather than waiting out the old one.
// biome-ignore lint/correctness/useExhaustiveDependencies: the window is read through the ref, but changing it is exactly what must restart the timer.
useEffect(() => {
if (!request) return
ask.current()
const timer = setInterval(() => ask.current(), refreshS * 1000)
return () => clearInterval(timer)
}, [request, rangeS, intervalS, refreshS])
// The window changed, so what is on screen is no longer an answer to it.
// biome-ignore lint/correctness/useExhaustiveDependencies: the window is the signal; clearing is not derived from the answer.
useEffect(() => setAnswer(null), [rangeS, intervalS, message])
// Keep only an answer computed for what we asked. Anything else belongs to
// another chart's window and would draw the wrong picture under our label.
useEffect(() => {
const payload = asPayload(live?.value)
if (!payload) return
if (payload.range_s !== rangeS || payload.interval_s !== intervalS) return
setAnswer(payload)
inflight.delete(`${request}|${rangeS}|${intervalS}`)
// The store hands out a fresh object per value, so its identity is the
// signal that something arrived — no timestamp needed beside it.
}, [live?.value, request, rangeS, intervalS])
if (!request || !message) {
return <p className="text-sm text-muted-foreground">Pick a message.</p>
}
const lines = (answer?.lines ?? []).slice(0, MAX_SERIES)
return (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<RangePicker value={range} onChange={setRange} />
<UplotChart
labels={lines.map((line, index) => line.label || `Series ${index + 1}`)}
plots={lines.map((line) =>
(line.points ?? []).map(([ts, value]) => ({ ts, value })),
)}
empty="Waiting for an answer."
{...presentation(cfg)}
/>
</div>
)
}
+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}