From e19776aed1c6c7f076ca452291e4121699d3e55b Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 17 Aug 2026 15:07:18 +0200 Subject: [PATCH] dashboard: charts that query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/components/Dashboard/ChartWidget.tsx | 180 +++++++++- frontend/src/components/Dashboard/panels.tsx | 310 +++++++++++++++--- 2 files changed, 437 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/Dashboard/ChartWidget.tsx b/frontend/src/components/Dashboard/ChartWidget.tsx index 3da9b75..b5e8ea8 100644 --- a/frontend/src/components/Dashboard/ChartWidget.tsx +++ b/frontend/src/components/Dashboard/ChartWidget.tsx @@ -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() + +/** 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 + +/** The unit and fixed axis a chart is drawn with, in either mode. */ +function presentation(cfg: Record) { + 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" ? ( + + ) : ( + + ) +} + /** * 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

Pick a message.

} - return + return ( + )} + /> + ) +} + +/** + * 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( + () => + 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(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

Pick a message.

+ } + + const lines = (answer?.lines ?? []).slice(0, MAX_SERIES) + return ( +
+ + line.label || `Series ${index + 1}`)} + plots={lines.map((line) => + (line.points ?? []).map(([ts, value]) => ({ ts, value })), + )} + empty="Waiting for an answer." + {...presentation(cfg)} + /> +
+ ) } diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index f05ee14..fdeaba7 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -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 (
@@ -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 ( +
+ Where the chart's data comes from + {( + [ + ["live", "Live"], + ["query", "Query"], + ] as const + ).map(([mode, label]) => ( + + ))} +
+ ) +} + /** * 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 (
) : widget.type === "chart" ? ( -
- {series.map((entry, index) => ( -
-
- - setSeries( - series.map((other, at) => - at === index ? { ...other, message, dtype } : other, - ), - ) - } - /> -
- + onPick={(request, request_dtype) => + set({ request, request_dtype }) + } + /> + message.dtype === "series"} + onPick={(message, dtype) => set({ message, dtype })} + />
- ))} - {series.length < MAX_SERIES ? ( - - ) : null} + ) : ( +
+ {series.map((entry, index) => ( +
+
+ + setSeries( + series.map((other, at) => + at === index + ? { ...other, message, dtype } + : other, + ), + ) + } + /> +
+ + setSeries( + series.map((other, at) => + at === index + ? { ...other, label: event.target.value } + : other, + ), + ) + } + /> + +
+ ))} + {series.length < MAX_SERIES ? ( + + ) : null} +
+ )}
) : ( - {widget.type === "chart" ? ( + {widget.type === "chart" && !querying ? (
) : null} - {widget.type === "stat" || widget.type === "gauge" ? ( + {widget.type === "chart" && querying ? ( + <> +
+ + + set({ refresh_s: Number(event.target.value) || 0 }) + } + /> +

+ How often it asks again. {MIN_REFRESH_S} seconds is the floor — + someone has to run the query. +

+
+
+ + +

+ A viewer can pick another on the widget itself. +

+
+ + ) : null} + + {widget.type === "agenda" ? ( +
+ + + set({ count: Number(event.target.value) || 0 }) + } + /> +
+ ) : null} + + {widget.type === "chart" ? ( +
+
+ + + set({ + y_min: + event.target.value === "" + ? undefined + : Number(event.target.value), + }) + } + /> +
+
+ + + set({ + y_max: + event.target.value === "" + ? undefined + : Number(event.target.value), + }) + } + /> +
+
+ ) : null} + + {widget.type === "stat" || + widget.type === "gauge" || + widget.type === "chart" || + widget.type === "slider" ? (
+ {widget.type === "slider" ? ( +
+ + + set({ step: Number(event.target.value) || 1 }) + } + /> +
+ ) : null}
) : null}