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>
278 lines
9.7 KiB
TypeScript
278 lines
9.7 KiB
TypeScript
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 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.
|
|
*
|
|
* 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<Record<string, HistoryPoint[]>>({})
|
|
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<string, HistoryPoint[]> = {}
|
|
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 <p className="text-sm text-muted-foreground">Pick a message.</p>
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|