Files
app/frontend/src/components/Dashboard/ChartWidget.tsx
T
stroblmeandClaude Fable 5 af3ba51571 Keep the engine's own history, and a screen that reads it
A second bus subscriber folds executions, errors, timings and queue lag
into per-minute rollups, keeps failures with their traceback and an audit
trail of who published what, and records one row per cascade — manual runs
and previews included, under an id of their own that writes no idempotency
markers. Read back through /observability/*, which always answers 200 so a
degraded engine still renders its own health screen.

Also fixes two things found on the way: node-health alerts read `status`
where the engine publishes `health`, so a device dropping never alerted
anyone, and the Redis queue reported `parked: 0` whatever was held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MeiWk3Yq12n2pTvnQWYvt
2026-08-16 22:29:32 +02:00

106 lines
3.7 KiB
TypeScript

import { useQueries } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import type { HistoryPoint } from "@/client"
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
import { useLiveValue } from "@/components/Flow/liveStore"
import { messageHistoryQueryOptions } 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
/** 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)
}
/**
* 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.
*/
export function ChartWidget({ 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} />
}