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
@@ -49,7 +49,7 @@ function useSettled(value: string): string {
* that never moved has no span to divide by, and one spanning decades is only
* legible once the exponent is what varies.
*/
function shape(points: HistoryPoint[]) {
export function shape(points: HistoryPoint[]) {
const values = points.map((point) => point.value)
const low = Math.min(...values)
const high = Math.max(...values)
+49
View File
@@ -24,6 +24,19 @@ export type LiveStatus = {
status: "active" | "error" | "running" | "success"
error?: string | null
}
/** How a node's connection is doing, which is not how its last run went. */
export type NodeHealth = {
health: "ok" | "down" | "unknown"
detail?: string | null
}
/** Something the engine reported about itself, for the health page. */
export type EngineEvent = {
type: string
flow?: string
node?: string
detail?: string
ts: number
}
/** One node execution's output, as the log panel shows it. */
export type LogLine = {
flow: string
@@ -38,9 +51,13 @@ type Listener = () => void
/** Enough to see what a flow has been doing, not a log store. */
const LOG_LIMIT = 500
/** The health page reads these to know when to refetch; it is not a history. */
const ENGINE_EVENT_LIMIT = 100
const values = new Map<string, LiveValue>()
const statuses = new Map<string, LiveStatus>()
const health = new Map<string, NodeHealth>()
let engineEvents: EngineEvent[] = []
// How many times a node has emitted. The number itself means nothing; a change
// is what restarts the pulse.
const emits = new Map<string, number>()
@@ -100,6 +117,18 @@ export const liveStore = {
getStatus(nodeId: string) {
return statuses.get(nodeId)
},
setHealth(nodeId: string, entry: NodeHealth) {
health.set(nodeId, entry)
notify(`health:${nodeId}`)
},
getHealth(nodeId: string) {
return health.get(nodeId)
},
recordEngineEvent(event: EngineEvent) {
// A new array each time, so the hook's snapshot comparison sees the change.
engineEvents = [...engineEvents, event].slice(-ENGINE_EVENT_LIMIT)
notify("engine")
},
recordEmit(nodeId: string) {
emits.set(nodeId, (emits.get(nodeId) ?? 0) + 1)
notify(`emit:${nodeId}`)
@@ -146,6 +175,10 @@ export const liveStore = {
statuses.clear()
for (const key of emits.keys()) notify(`emit:${key}`)
emits.clear()
for (const key of health.keys()) notify(`health:${key}`)
health.clear()
engineEvents = []
notify("engine")
logLines = []
notify("logs")
for (const flow of paused) notify(`paused:${flow}`)
@@ -167,6 +200,22 @@ export function useNodeStatus(nodeId: string): LiveStatus | undefined {
)
}
/** How the node's connection is doing, once it has said anything about it. */
export function useNodeHealth(nodeId: string): NodeHealth | undefined {
return useSyncExternalStore(
(listener) => subscribeKey(`health:${nodeId}`, listener),
() => health.get(nodeId),
)
}
/** The last hundred things the engine said about itself, oldest first. */
export function useEngineEvents(): EngineEvent[] {
return useSyncExternalStore(
(listener) => subscribeKey("engine", listener),
() => engineEvents,
)
}
/** Increments each time the node publishes something. */
export function useNodeEmits(nodeId: string): number {
return useSyncExternalStore(
+77 -2
View File
@@ -27,11 +27,48 @@ type FlowEvent =
source?: ValueSource
}
| { type: "node_started"; node: string }
| { type: "node_executed"; node: string; outputs: number }
| { type: "node_error"; node: string; error: string }
| {
type: "node_executed"
flow?: string
node: string
outputs: number
duration_ms?: number
}
| {
type: "node_error"
flow?: string
node: string
error: string
ts?: number
}
| { type: "node_status"; node: string; status: string; error?: string | null }
| ({ type: "node_log" } & LogLine)
| { type: "flow_paused"; flow: string; paused: boolean }
| {
type: "node_health"
flow?: string
node: string
health: "ok" | "down" | "unknown"
detail?: string | null
ts?: number
}
| { type: "flow_quarantined"; flow: string; error?: string; ts?: number }
| { type: "engine_degraded"; reason?: string; ts?: number }
| { type: "engine_fatal"; reason?: string; ts?: number }
| {
type: "cascade_dropped"
flow?: string
node?: string
deliveries?: number
ts?: number
}
| {
type: "queue_unavailable"
flow?: string
node?: string
error?: string
ts?: number
}
| {
type: "pipeline_rebuilt"
nodes: { id: string; status: string; error?: string | null }[]
@@ -110,6 +147,44 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
status: "error",
error: message.error,
})
liveStore.recordEngineEvent({
type: message.type,
flow: message.flow,
node: message.node,
detail: message.error,
ts: message.ts ?? Date.now() / 1000,
})
break
case "node_health":
liveStore.setHealth(message.node, {
health: message.health,
detail: message.detail,
})
if (message.health === "down") {
liveStore.recordEngineEvent({
type: message.type,
flow: message.flow,
node: message.node,
detail: message.detail ?? "Reported itself down.",
ts: message.ts ?? Date.now() / 1000,
})
}
break
case "flow_quarantined":
case "engine_degraded":
case "engine_fatal":
case "cascade_dropped":
case "queue_unavailable":
liveStore.recordEngineEvent({
type: message.type,
flow: "flow" in message ? message.flow : undefined,
node: "node" in message ? message.node : undefined,
detail:
("error" in message ? message.error : undefined) ??
("reason" in message ? message.reason : undefined) ??
"",
ts: message.ts ?? Date.now() / 1000,
})
break
case "node_status":
liveStore.setStatus(message.node, {