The one-hue ramp told lines apart by lightness alone. --chart-1 is now --primary and slots 2-8 are distinct hues (orange, violet, green, orchid, ochre, teal, magenta) that clear 3:1 on --card and stay apart under protanopia and deuteranopia; red and sage are left out as the status colours. A chart takes the slots in order instead of spreading a ramp, and a dashboard's own palette continues after its primary and listed colours with slots 2-8 rather than tints of its scheme. Line charts stay at five series. Tokens mirror index's, as design-check requires.
458 lines
16 KiB
TypeScript
458 lines
16 KiB
TypeScript
import { useQueries, useQuery } from "@tanstack/react-query"
|
|
import { useEffect, useRef, useState } from "react"
|
|
|
|
import type { HistoryPoint } from "@/client"
|
|
import {
|
|
DEFAULT_RANGE,
|
|
RANGES,
|
|
type Range,
|
|
} from "@/components/Common/RangePicker"
|
|
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
|
|
import { useLiveValue, useLiveValues } from "@/components/Flow/liveStore"
|
|
import {
|
|
compareQueryOptions,
|
|
NO_CURVE,
|
|
runsListQueryOptions,
|
|
STEP_AXIS,
|
|
shortenRunLabel,
|
|
} from "@/components/Runs/queries"
|
|
import { type DataContext, useDataContext } from "./dataContext"
|
|
import { messageHistoryQueryOptions, usePublishMessage } from "./queries"
|
|
import { usePalette } from "./settings"
|
|
import { useUi } from "./ui"
|
|
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<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, the fixed axis and the line shape, 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,
|
|
yLabel: cfg.y_label ? String(cfg.y_label) : undefined,
|
|
smooth: Boolean(cfg.smooth),
|
|
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) {
|
|
// A dashboard opened against something — runs today, a replayed day later —
|
|
// draws from that instead, whichever source it was pointed at otherwise.
|
|
const context = useDataContext()
|
|
if (context) return <ContextChart {...props} context={context} />
|
|
const source = config(props.widget).source
|
|
if (source === "runs") return <RunsChart {...props} />
|
|
return source === "query" ? (
|
|
<QueryChart {...props} />
|
|
) : (
|
|
<LiveChart {...props} />
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The last few runs of something, side by side, always.
|
|
*
|
|
* A tile rather than a way of looking at the page: what a panel over a lab
|
|
* bench shows without anyone picking runs first. The comparison endpoint
|
|
* already answers in the shape this draws, so the widget only has to decide
|
|
* which runs it means.
|
|
*/
|
|
function RunsChart({ widget }: WidgetProps) {
|
|
const cfg = (widget.config ?? {}) as {
|
|
runs?: {
|
|
metric?: string
|
|
flow?: string
|
|
group?: string
|
|
ids?: string[]
|
|
latest?: number
|
|
}
|
|
refresh_s?: number
|
|
}
|
|
const pick = cfg.runs ?? {}
|
|
const metric = pick.metric ?? ""
|
|
const explicit = pick.ids ?? []
|
|
const refreshMs = Math.max(15, Number(cfg.refresh_s) || 60) * 1000
|
|
|
|
// Named runs need no lookup. Otherwise the newest of a sweep, or of a flow —
|
|
// and of a flow only the ones that finished, since a panel comparing curves
|
|
// wants results rather than the crash from ten minutes ago.
|
|
const found = useQuery({
|
|
...runsListQueryOptions(
|
|
pick.group
|
|
? { group: pick.group }
|
|
: {
|
|
flow: pick.flow,
|
|
status: "ok",
|
|
limit: Math.min(Math.max(Number(pick.latest) || 3, 1), MAX_SERIES),
|
|
},
|
|
),
|
|
enabled: explicit.length === 0 && Boolean(pick.group || pick.flow),
|
|
})
|
|
|
|
const ids = explicit.length
|
|
? explicit.slice(0, MAX_SERIES)
|
|
: (found.data ?? []).slice(0, MAX_SERIES).map((run) => run.id)
|
|
|
|
const { data, isPending } = useQuery(
|
|
compareQueryOptions(ids, metric, STEP_AXIS, refreshMs),
|
|
)
|
|
const palette = usePalette()
|
|
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
|
|
const points = lines.reduce(
|
|
(total, line) => total + (line.points?.length ?? 0),
|
|
0,
|
|
)
|
|
|
|
if (!metric) return <p className="text-muted-foreground">Pick a metric.</p>
|
|
|
|
return (
|
|
<UplotChart
|
|
labels={lines.map((line) => shortenRunLabel(line.label))}
|
|
plots={lines.map((line) =>
|
|
(line.points ?? []).map(([step, value]) => ({ ts: step, value })),
|
|
)}
|
|
palette={palette}
|
|
xTime={false}
|
|
pending={isPending || found.isPending}
|
|
empty={points === 0 ? NO_CURVE : undefined}
|
|
{...presentation(widget.config as Record<string, unknown>)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The same chart, drawn from a data context.
|
|
*
|
|
* One line per member per bound message: three runs of a two-series chart is
|
|
* six curves, capped like any other chart at what the palette can tell apart.
|
|
*/
|
|
function ContextChart({
|
|
widget,
|
|
context,
|
|
}: WidgetProps & { context: DataContext }) {
|
|
const cfg = widget.config as {
|
|
series?: Series[]
|
|
runs?: { metric?: string }
|
|
}
|
|
const bound = [
|
|
...(cfg.series ?? []).map((entry) => entry.message),
|
|
cfg.runs?.metric,
|
|
].filter((name): name is string => Boolean(name))
|
|
|
|
const palette = usePalette()
|
|
const drawn = bound.flatMap((name) => {
|
|
const lines = context.lines(name) ?? []
|
|
// The message name is only worth writing when the chart draws more than
|
|
// one of them; otherwise the member's own label is the whole story.
|
|
return lines.map((line) => ({
|
|
label: bound.length > 1 ? `${name} · ${line.label}` : line.label,
|
|
points: line.points,
|
|
}))
|
|
})
|
|
const shown = drawn.slice(0, MAX_SERIES)
|
|
const points = shown.reduce((total, line) => total + line.points.length, 0)
|
|
|
|
if (bound.length === 0) {
|
|
return <p className="text-muted-foreground">Pick a message.</p>
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<UplotChart
|
|
labels={shown.map((line) => line.label)}
|
|
plots={shown.map((line) => line.points)}
|
|
palette={palette}
|
|
xTime={context.xTime}
|
|
pending={context.pending}
|
|
empty={points === 0 ? NO_CURVE : undefined}
|
|
{...presentation(widget.config as Record<string, unknown>)}
|
|
/>
|
|
{drawn.length > MAX_SERIES && (
|
|
<p className="text-muted-foreground text-xs">
|
|
Showing {MAX_SERIES} of {drawn.length} lines.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
)
|
|
|
|
// The dashboard's own data colours; outside one, none, and the chart takes
|
|
// the shared palette.
|
|
const palette = usePalette()
|
|
|
|
const histories = useQueries({
|
|
queries: names.map((name) => messageHistoryQueryOptions(name)),
|
|
})
|
|
|
|
const live = useLiveValues(names)
|
|
|
|
// 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-muted-foreground">Pick a message.</p>
|
|
}
|
|
|
|
return (
|
|
<UplotChart
|
|
labels={labels}
|
|
plots={plots}
|
|
palette={palette}
|
|
{...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 [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
|
|
// 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 palette = usePalette()
|
|
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-muted-foreground">Pick a message.</p>
|
|
}
|
|
|
|
const lines = (answer?.lines ?? []).slice(0, MAX_SERIES)
|
|
return (
|
|
// The window sits down the right-hand edge rather than on the title's
|
|
// line: a tile is short, and every row the chrome takes is a row the plot
|
|
// does not get. A column costs it about a fortieth of its width instead.
|
|
<div className="dui-chart grid h-full min-h-0 grid-cols-[minmax(0,1fr)_auto] gap-2">
|
|
<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."
|
|
palette={palette}
|
|
{...presentation(cfg)}
|
|
/>
|
|
<RangeRail range={range} onChange={setRange} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** The window a chart is showing, as a column beside it. */
|
|
function RangeRail({
|
|
range,
|
|
onChange,
|
|
}: {
|
|
range: Range
|
|
onChange: (range: Range) => void
|
|
}) {
|
|
const { Segmented } = useUi()
|
|
return (
|
|
<Segmented
|
|
orientation="vertical"
|
|
testId="range-picker"
|
|
label="Time range"
|
|
value={String(range.hours)}
|
|
options={RANGES.map(
|
|
(entry) => [String(entry.hours), entry.label] as const,
|
|
)}
|
|
onChange={(hours) =>
|
|
onChange(
|
|
RANGES.find((entry) => String(entry.hours) === hours) ??
|
|
DEFAULT_RANGE,
|
|
)
|
|
}
|
|
/>
|
|
)
|
|
}
|