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}