The socket belonged to whichever hook instance ran its effect first. Passive effects run children before parents, so on Home that was the brain graph rather than the shell: navigating to a sibling route unmounted the graph, which closed the socket, while the shell kept the reference count above zero. From there the page was deaf for the rest of its life, with nothing left to reconnect it. A module-level connection with a real refcount replaces it — connect on the first subscriber, disconnect on the last — and the hook is a thin subscription with the same signature, correct under StrictMode's mount/unmount/mount. A 1008 now reconnects instead of returning silently: the token is read afresh per attempt, and three consecutive rejections fall through to the caller's auth handler so a revoked session surfaces rather than spins. The snapshot's emit counts are read into a store of their own, apart from the live count, so a graph that connects into a busy engine is drawn as busy without every neuron claiming it just fired. The neuron and edge pulses now key off a change seen while they were mounted, so returning to Home no longer replays every emission of the session. Home gets a live indicator for the case none of this can fix: quiet while the socket is up, and named in words when it is down, since HTTP polling keeps the rest of the page looking current.
372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
import { useCallback, useSyncExternalStore } from "react"
|
|
|
|
/**
|
|
* Live engine state, deliberately outside React Query.
|
|
*
|
|
* Values can arrive many times a second. Keeping them here, with one subscriber
|
|
* set per key, means a changing value re-renders the chip showing it and
|
|
* nothing else.
|
|
*/
|
|
|
|
/** Who caused a value. The canvas needs it to pulse the right edge. */
|
|
export type ValueSource = {
|
|
kind: "node" | "dashboard" | "flow" | "agent" | "api"
|
|
id: string
|
|
label: string
|
|
detail?: string
|
|
}
|
|
export type LiveValue = {
|
|
value: unknown
|
|
ts: number | null
|
|
source?: ValueSource
|
|
}
|
|
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
|
|
node: string
|
|
text: string
|
|
level: "info" | "error"
|
|
truncated?: boolean
|
|
ts: number
|
|
}
|
|
|
|
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 this page has seen a node emit. The number itself means
|
|
// nothing; a change is what restarts the pulse.
|
|
const emits = new Map<string, number>()
|
|
// What the engine had already counted when we connected, from the snapshot.
|
|
// Kept apart from the live count on purpose: it says the node has been busy,
|
|
// which is worth drawing, but it happened before anyone was watching, and a
|
|
// pulse claims something is happening now.
|
|
const priorEmits = new Map<string, number>()
|
|
let logLines: LogLine[] = []
|
|
const paused = new Set<string>()
|
|
const listeners = new Map<string, Set<Listener>>()
|
|
|
|
let connected = false
|
|
const connectionListeners = new Set<Listener>()
|
|
|
|
function notify(key: string) {
|
|
for (const listener of listeners.get(key) ?? []) listener()
|
|
}
|
|
|
|
function subscribeKey(key: string, listener: Listener) {
|
|
let set = listeners.get(key)
|
|
if (!set) {
|
|
set = new Set()
|
|
listeners.set(key, set)
|
|
}
|
|
set.add(listener)
|
|
return () => {
|
|
set.delete(listener)
|
|
if (set.size === 0) listeners.delete(key)
|
|
}
|
|
}
|
|
|
|
export const liveStore = {
|
|
setValue(name: string, value: LiveValue) {
|
|
values.set(name, value)
|
|
notify(`value:${name}`)
|
|
},
|
|
setValues(entries: Record<string, LiveValue>) {
|
|
for (const [name, value] of Object.entries(entries)) {
|
|
values.set(name, value)
|
|
notify(`value:${name}`)
|
|
}
|
|
},
|
|
getValue(name: string) {
|
|
return values.get(name)
|
|
},
|
|
setStatus(nodeId: string, status: LiveStatus) {
|
|
statuses.set(nodeId, status)
|
|
notify(`status:${nodeId}`)
|
|
},
|
|
setStatuses(
|
|
entries: { id: string; status: string; error?: string | null }[],
|
|
) {
|
|
for (const entry of entries) {
|
|
statuses.set(entry.id, {
|
|
status: entry.status as LiveStatus["status"],
|
|
error: entry.error,
|
|
})
|
|
notify(`status:${entry.id}`)
|
|
}
|
|
},
|
|
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}`)
|
|
},
|
|
/**
|
|
* The counts a snapshot carries, so a page that connects into an engine
|
|
* already at work draws what has been busy rather than a graph of idle
|
|
* neurons.
|
|
*
|
|
* Absent keys are left alone rather than cleared: a restarted engine counts
|
|
* from zero again, which is no reason to forget what this page saw.
|
|
*/
|
|
setEmits(entries: Record<string, number>) {
|
|
for (const [nodeId, count] of Object.entries(entries)) {
|
|
if (priorEmits.get(nodeId) === count) continue
|
|
priorEmits.set(nodeId, count)
|
|
notify(`emit:${nodeId}`)
|
|
}
|
|
},
|
|
appendLog(line: LogLine) {
|
|
// A new array each time, so the hook's snapshot comparison sees the change.
|
|
logLines = [...logLines, line].slice(-LOG_LIMIT)
|
|
notify("logs")
|
|
},
|
|
setLogs(lines: LogLine[]) {
|
|
logLines = lines.slice(-LOG_LIMIT)
|
|
notify("logs")
|
|
},
|
|
clearLogs() {
|
|
logLines = []
|
|
notify("logs")
|
|
},
|
|
setPaused(flow: string, isPaused: boolean) {
|
|
if (isPaused) paused.add(flow)
|
|
else paused.delete(flow)
|
|
notify(`paused:${flow}`)
|
|
},
|
|
setPausedFlows(flows: string[]) {
|
|
const next = new Set(flows)
|
|
for (const flow of new Set([...paused, ...next])) {
|
|
if (paused.has(flow) === next.has(flow)) continue
|
|
if (next.has(flow)) paused.add(flow)
|
|
else paused.delete(flow)
|
|
notify(`paused:${flow}`)
|
|
}
|
|
},
|
|
setConnected(next: boolean) {
|
|
if (connected === next) return
|
|
connected = next
|
|
for (const listener of connectionListeners) listener()
|
|
},
|
|
isConnected() {
|
|
return connected
|
|
},
|
|
reset() {
|
|
for (const key of values.keys()) notify(`value:${key}`)
|
|
values.clear()
|
|
for (const key of statuses.keys()) notify(`status:${key}`)
|
|
statuses.clear()
|
|
for (const key of new Set([...emits.keys(), ...priorEmits.keys()]))
|
|
notify(`emit:${key}`)
|
|
emits.clear()
|
|
priorEmits.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}`)
|
|
paused.clear()
|
|
},
|
|
}
|
|
|
|
export function useLiveValue(name: string | undefined): LiveValue | undefined {
|
|
return useSyncExternalStore(
|
|
(listener) => (name ? subscribeKey(`value:${name}`, listener) : () => {}),
|
|
() => (name ? values.get(name) : undefined),
|
|
)
|
|
}
|
|
|
|
export function useNodeStatus(nodeId: string): LiveStatus | undefined {
|
|
return useSyncExternalStore(
|
|
(listener) => subscribeKey(`status:${nodeId}`, listener),
|
|
() => statuses.get(nodeId),
|
|
)
|
|
}
|
|
|
|
/** 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(
|
|
(listener) => subscribeKey(`emit:${nodeId}`, listener),
|
|
() => emits.get(nodeId) ?? 0,
|
|
)
|
|
}
|
|
|
|
/** Every captured line, newest last. Filtered by the panel that shows it. */
|
|
export function useLiveLogs(): LogLine[] {
|
|
return useSyncExternalStore(
|
|
(listener) => subscribeKey("logs", listener),
|
|
() => logLines,
|
|
)
|
|
}
|
|
|
|
export function useFlowPaused(flow: string): boolean {
|
|
return useSyncExternalStore(
|
|
(listener) => subscribeKey(`paused:${flow}`, listener),
|
|
() => paused.has(flow),
|
|
)
|
|
}
|
|
|
|
/*
|
|
* Group hooks, for the brain graph: one neuron stands for several nodes, and
|
|
* one edge for several messages.
|
|
*
|
|
* Each returns a primitive. `useSyncExternalStore` compares snapshots by
|
|
* identity, so a fresh object or array every render would re-render forever.
|
|
* The names arrive as an array, which is also new every render, so they are
|
|
* joined into one string before anything hangs off them.
|
|
*/
|
|
|
|
const SEP = "\u0000"
|
|
|
|
/** Back to the array. `"".split(SEP)` is `[""]`, which is one empty name. */
|
|
const parts = (joined: string) => (joined ? joined.split(SEP) : [])
|
|
|
|
function subscribeAll(keys: string[], listener: Listener) {
|
|
const unsubscribes = keys.map((key) => subscribeKey(key, listener))
|
|
return () => {
|
|
for (const unsubscribe of unsubscribes) unsubscribe()
|
|
}
|
|
}
|
|
|
|
/** How many times any of these nodes has published. A change is a pulse. */
|
|
export function useGroupEmits(ids: string[]): number {
|
|
const joined = ids.join(SEP)
|
|
return useSyncExternalStore(
|
|
useCallback(
|
|
(listener: Listener) =>
|
|
subscribeAll(
|
|
parts(joined).map((id) => `emit:${id}`),
|
|
listener,
|
|
),
|
|
[joined],
|
|
),
|
|
() => parts(joined).reduce((total, id) => total + (emits.get(id) ?? 0), 0),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Whether any of these nodes has ever published, this page or before it.
|
|
*
|
|
* Separate from the count above because it answers a different question: the
|
|
* count is what pulses, and this is what tells a neuron nobody has ever seen
|
|
* run from one that is merely quiet just now.
|
|
*/
|
|
export function useGroupActive(ids: string[]): boolean {
|
|
const joined = ids.join(SEP)
|
|
return useSyncExternalStore(
|
|
useCallback(
|
|
(listener: Listener) =>
|
|
subscribeAll(
|
|
parts(joined).map((id) => `emit:${id}`),
|
|
listener,
|
|
),
|
|
[joined],
|
|
),
|
|
() =>
|
|
parts(joined).some(
|
|
(id) => (emits.get(id) ?? 0) + (priorEmits.get(id) ?? 0) > 0,
|
|
),
|
|
)
|
|
}
|
|
|
|
/** Whether any of these nodes is currently failing. */
|
|
export function useGroupError(ids: string[]): boolean {
|
|
const joined = ids.join(SEP)
|
|
return useSyncExternalStore(
|
|
useCallback(
|
|
(listener: Listener) =>
|
|
subscribeAll(
|
|
parts(joined).map((id) => `status:${id}`),
|
|
listener,
|
|
),
|
|
[joined],
|
|
),
|
|
() => parts(joined).some((id) => statuses.get(id)?.status === "error"),
|
|
)
|
|
}
|
|
|
|
/** When any of these messages last carried a value. */
|
|
export function useLatestTs(names: string[]): number {
|
|
const joined = names.join(SEP)
|
|
return useSyncExternalStore(
|
|
useCallback(
|
|
(listener: Listener) =>
|
|
subscribeAll(
|
|
parts(joined).map((name) => `value:${name}`),
|
|
listener,
|
|
),
|
|
[joined],
|
|
),
|
|
() =>
|
|
parts(joined).reduce(
|
|
(latest, name) => Math.max(latest, values.get(name)?.ts ?? 0),
|
|
0,
|
|
),
|
|
)
|
|
}
|
|
|
|
export function useLiveConnection(): boolean {
|
|
return useSyncExternalStore(
|
|
(listener) => {
|
|
connectionListeners.add(listener)
|
|
return () => connectionListeners.delete(listener)
|
|
},
|
|
() => connected,
|
|
)
|
|
}
|