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
106 lines
3.7 KiB
TypeScript
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} />
|
|
}
|