import { useQueries } from "@tanstack/react-query" 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, usePublishMessage } from "./queries" import type { Series, WidgetProps } from "./widgets" // The five-series ceiling is the chart host's, and the panel reads it here. export { MAX_SERIES } /** The window a chart draws, unless it asks for more. */ const DEFAULT_POINTS = 300 /** * How often a chart asks again, for the window it is showing. * * A window is drawn in buckets, and nothing the chart can show changes until * the bucket it is drawing closes — so the resolution sets the pace. A week at * quarter-hour buckets asks four times an hour instead of twice a minute, for * the same picture. A slower refresh than that is honoured; a faster one only * buys the same answer again, so it is clamped. */ export const refreshFor = (range: Range, configured: unknown) => Math.max(range.bucketS, Number(configured) || 0) /** 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, yLabel: cfg.y_label ? String(cfg.y_label) : 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. * * uPlot rather than SVG: a chart may hold five series of up to `HISTORY_CAP` * points each, which is more path data than React should be rebuilding on every * value that arrives. Its own legend doubles as the hover readout, so the * cursor tells you what each line was worth at that moment. */ 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 ?? []) .filter((entry): entry is Series & { message: string } => Boolean(entry.message), ) .slice(0, MAX_SERIES) const names = series.map((entry) => entry.message) const cap = Math.max( 2, Number( (widget.config as { history?: { points?: number } })?.history?.points, ) || DEFAULT_POINTS, ) const histories = useQueries({ queries: names.map((name) => messageHistoryQueryOptions(name)), }) // A fixed set: hooks cannot be called in a loop, and five is the ceiling. const live = [ useLiveValue(names[0]), useLiveValue(names[1]), useLiveValue(names[2]), useLiveValue(names[3]), useLiveValue(names[4]), ] // What arrived over the socket since the history was fetched. Appending beats // refetching the whole series every time a value lands. const [tails, setTails] = useState>({}) const key = names.join(" ") const stamps = live.map((value) => value?.ts ?? 0).join(",") // biome-ignore lint/correctness/useExhaustiveDependencies: the timestamps are what makes a tail longer; `live` is a fresh array every render. useEffect(() => { setTails((previous) => { const next: Record = {} let changed = false names.forEach((name, index) => { const point = live[index] const kept = previous[name] ?? [] const ts = point?.ts if ( typeof point?.value !== "number" || !ts || kept[kept.length - 1]?.ts === ts ) { next[name] = kept return } next[name] = [...kept, { ts, value: point.value }].slice(-cap) changed = true }) // Dropping a series is a change too, even without a new reading. return changed || Object.keys(previous).length !== names.length ? next : previous }) }, [key, stamps, cap]) const plots = names.map((name, index) => merge( (histories[index]?.data?.points ?? []) as HistoryPoint[], tails[name] ?? [], cap, ), ) const labels = series.map((entry, index) => entry.label || names[index]) if (names.length === 0) { return

Pick a message.

} 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 [range, setRange] = useState( () => RANGES.find((r) => r.hours * 3600 === Number(cfg.range_s)) ?? DEFAULT_RANGE, ) const rangeS = range.hours * 3600 const intervalS = range.bucketS // Follows the window: a wider one is drawn coarser, so it is worth asking // about less often. const refreshS = refreshFor(range, cfg.refresh_s) 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)} />
) }