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
This commit is contained in:
2026-08-16 22:29:32 +02:00
co-authored by Claude Fable 5
parent f300c43f3a
commit af3ba51571
30 changed files with 2610 additions and 22 deletions
@@ -0,0 +1,163 @@
import { useEffect, useLayoutEffect, useRef } from "react"
import uPlot from "uplot"
import "uplot/dist/uPlot.min.css"
import type { HistoryPoint } from "@/client"
import { useTheme } from "@/components/theme-provider"
/**
* How many lines one chart carries.
*
* The bound is the palette's: `--chart-1…5` is one designed ramp, and a sixth
* line would either repeat a step or invent a colour outside the system.
*/
export const MAX_SERIES = 5
/** Room for the axis ticks; uPlot measures the rest of the box itself. */
const PADDING: uPlot.Padding = [10, 12, 0, 0]
/** The legend sits under the canvas, so the canvas has to leave it room. */
const LEGEND_HEIGHT = 26
const canvasHeight = (element: HTMLElement) =>
Math.max(60, (element.clientHeight || 180) - LEGEND_HEIGHT)
/** A token, resolved for the canvas — which cannot read CSS variables. */
function token(name: string): string {
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim()
}
const seriesColor = (index: number) => token(`--chart-${(index % 5) + 1}`)
/** The series joined onto one x axis, which is what uPlot draws. */
function table(plots: HistoryPoint[][]): uPlot.AlignedData {
return uPlot.join(
plots.map(
(plot) =>
[
plot.map((point) => point.ts),
plot.map((point) => point.value),
] as uPlot.AlignedData,
),
)
}
/**
* Several series over time, drawn on one axis.
*
* uPlot rather than SVG: a chart may hold five series of hundreds of 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 — and with more than one
* line a legend is required anyway.
*/
export function UplotChart({
labels,
plots,
empty = "Nothing has come through yet.",
}: {
/** One label per series; the set of them is the chart's identity. */
labels: string[]
/** The points of each series, in the same order as `labels`. */
plots: HistoryPoint[][]
empty?: string
}) {
const host = useRef<HTMLDivElement>(null)
const chart = useRef<uPlot | null>(null)
const { resolvedTheme } = useTheme()
const points = plots.reduce((total, plot) => total + plot.length, 0)
// The identity of the series set: the chart is rebuilt when it changes,
// while a new reading only sets its data.
const key = labels.join(" ")
// uPlot leaves its axes half-initialised while the scales have no range, and
// a resize in that window (a card still settling, say) draws them anyway and
// throws. Waiting for the first reading avoids the state altogether.
const ready = points > 0
// biome-ignore lint/correctness/useExhaustiveDependencies: the label string is the identity of the series set.
useLayoutEffect(() => {
const element = host.current
if (!element || labels.length === 0 || !ready) return
const axis = {
stroke: () => token("--muted-foreground"),
grid: { stroke: () => token("--border"), width: 1 },
ticks: { stroke: () => token("--border"), width: 1 },
font: `11px ${getComputedStyle(element).fontFamily}`,
}
const plot = new uPlot(
{
width: element.clientWidth || 320,
height: canvasHeight(element),
padding: PADDING,
cursor: { y: false },
legend: { live: true },
scales: { x: { time: true } },
axes: [
{ ...axis, size: 28 },
{ ...axis, size: 46 },
],
series: [
{},
...labels.map((label, index) => ({
label,
width: 2,
// Read at draw time, so a theme toggle is a redraw rather than a
// rebuilt chart.
stroke: () => seriesColor(index),
// Series arrive on their own clocks; a joined table is mostly
// holes, and a line with a hole per point is not a line.
spanGaps: true,
points: { show: false },
})),
],
},
// Built with the readings it already has: uPlot's axes are only half
// initialised while its scales have no range.
table(plots),
element,
)
chart.current = plot
const observer = new ResizeObserver(() => {
plot.setSize({
width: element.clientWidth,
height: canvasHeight(element),
})
})
observer.observe(element)
return () => {
observer.disconnect()
plot.destroy()
chart.current = null
}
}, [key, ready])
// biome-ignore lint/correctness/useExhaustiveDependencies: rebuilding the joined table is what the point count stands for.
useEffect(() => {
if (!chart.current || plots.length === 0) return
chart.current.setData(table(plots))
}, [points, key])
// The canvas cannot follow a CSS variable, so a theme swap is a redraw.
// biome-ignore lint/correctness/useExhaustiveDependencies: the theme is the signal, not something the effect reads.
useEffect(() => {
chart.current?.redraw()
}, [resolvedTheme])
return (
<div className="relative min-h-0 flex-1">
<div ref={host} className="absolute inset-0" />
{points === 0 ? (
<p className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
{empty}
</p>
) : null}
</div>
)
}