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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,6 +231,35 @@ export function WidgetPanel({
|
||||
/>
|
||||
</div>
|
||||
) : widget.type === "chart" ? (
|
||||
<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
|
||||
}
|
||||
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
|
||||
@@ -195,12 +276,29 @@ export function WidgetPanel({
|
||||
onPick={(message, dtype) =>
|
||||
setSeries(
|
||||
series.map((other, at) =>
|
||||
at === index ? { ...other, message, dtype } : other,
|
||||
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"
|
||||
@@ -227,6 +325,8 @@ export function WidgetPanel({
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<MessagePicker
|
||||
kind={widget.type}
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user