/** * `useTelemetry` — the behavior feed. * * Two modes: * * LIVE — holds an SSE connection to `/__nwire/telemetry/live`, which tails * the current (newest) run file and streams appended records. * A `run` event from the server backfills the current run on open. * * HISTORY — fetches a chosen past run's records once via * `/__nwire/telemetry/runs/:id` and loads them as a static snapshot. * * Switching modes (`source`) clears the buffer and reconnects. Both modes share * the same typed-record parsing/grouping/tree helpers from `../lib/telemetry`. * * Side-effecting (EventSource + fetch) but DI'd: tests inject a fake * EventSource factory + fetch. The pure parsing/grouping/tree live in * `../lib/telemetry`. */ import { computed, ref, shallowRef, onScopeDispose, toValue, watch, type MaybeRefOrGetter, } from "vue"; import { parseRecord, groupByCorrelation, buildCorrelationTree, isFailure, type TelemetryRecord, } from "../lib/telemetry"; /** The SSE tail of the current (newest) run file. */ const LIVE_URL = "/__nwire/telemetry/live"; /** Run list: returns { runs: TelemetryRunMeta[] }. */ export const RUNS_URL = "/__nwire/telemetry/runs"; /** A past run as returned by GET /__nwire/telemetry/runs. */ export interface TelemetryRunMeta { readonly id: string; readonly size: number; readonly mtime: string; } /** * The data source for useTelemetry. * "live" — stream the current run via SSE. * run meta — load a specific past run as a static snapshot. */ export type TelemetrySource = "live" | TelemetryRunMeta; /** Minimal EventSource surface we depend on — lets tests inject a fake. */ export interface EventSourceLike { onmessage: ((ev: { data: string }) => void) | null; onerror: ((ev?: unknown) => void) | null; onopen: ((ev?: unknown) => void) | null; /** Listen for named events (e.g. the `run` meta event). */ addEventListener?(type: string, listener: (ev: { data: string }) => void): void; close(): void; } export type ConnectionStatus = "idle" | "connecting" | "open" | "reconnecting" | "closed"; export interface UseTelemetryOptions { /** Ring-buffer cap. Default 1000. */ readonly limit?: number; /** Connect immediately. Default true. */ readonly autoConnect?: boolean; /** Inject an EventSource (tests). Default `new EventSource(url)`. */ readonly eventSourceFactory?: (url: string) => EventSourceLike; /** Inject fetch (tests). Default global `fetch`. */ readonly fetchFn?: typeof fetch; /** Reconnect backoff in ms (live mode only). Default 1500. */ readonly reconnectMs?: number; } export function useTelemetry( project?: MaybeRefOrGetter, options: UseTelemetryOptions = {}, ) { const limit = options.limit ?? 1000; const reconnectMs = options.reconnectMs ?? 1500; const makeSource = options.eventSourceFactory ?? ((url) => new EventSource(url) as EventSourceLike); const doFetch = options.fetchFn ?? ((...a: Parameters) => fetch(...a)); const records = shallowRef([]); const status = ref("idle"); /** The run id that the live SSE stream is tailing (null when none/history). */ const currentRunId = ref(null); /** * The active source. "live" = streaming; a TelemetryRunMeta = past run * snapshot. Callers set this to switch modes. */ const source = ref("live"); let es: EventSourceLike | null = null; let reconnectTimer: ReturnType | null = null; let disposed = false; function push(rec: TelemetryRecord): void { const next = records.value.concat(rec); records.value = next.length > limit ? next.slice(next.length - limit) : next; } function clear(): void { records.value = []; currentRunId.value = null; } // ── HISTORY mode ──────────────────────────────────────────────────────── async function loadRun(run: TelemetryRunMeta): Promise { status.value = "connecting"; try { const url = `${RUNS_URL}/${encodeURIComponent(run.id)}`; const res = await doFetch(url); if (!res.ok) { status.value = "closed"; return; } const body = (await res.json()) as { records?: unknown[] }; const parsed = (body.records ?? []) .map(parseRecord) .filter((r): r is TelemetryRecord => r !== null); records.value = parsed.slice(-limit); currentRunId.value = run.id; status.value = "open"; } catch { status.value = "closed"; } } // ── LIVE mode ──────────────────────────────────────────────────────────── function openLive(): void { if (disposed) return; status.value = status.value === "open" ? "reconnecting" : "connecting"; const src = makeSource(LIVE_URL); es = src; // The server emits a `run` event with the current run meta. src.addEventListener?.("run", (ev) => { try { const meta = JSON.parse(ev.data) as TelemetryRunMeta | null; currentRunId.value = meta?.id ?? null; } catch { // ignore } }); src.onopen = () => { status.value = "open"; }; src.onmessage = (ev) => { // Default `message` events carry telemetry records (backfill + live). const rec = parseRecord(ev.data); if (rec) push(rec); }; src.onerror = () => { if (disposed) return; status.value = "reconnecting"; src.close(); es = null; if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = setTimeout(() => openLive(), reconnectMs); }; } // ── Public connect/disconnect ───────────────────────────────────────────── function connect(): void { if (disposed) return; clear(); const s = source.value; if (s === "live") { if (es) return; // already connected openLive(); } else { void loadRun(s); } } function disconnect(): void { if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = null; es?.close(); es = null; status.value = "closed"; } // Re-key on project change or source change: drop everything + reconnect. watch([() => toValue(project), source], () => { if (disposed) return; disconnect(); clear(); disposed = false; connect(); }); if (options.autoConnect !== false) connect(); onScopeDispose(() => { disposed = true; disconnect(); }); const byCorrelation = computed(() => groupByCorrelation(records.value)); const tree = computed(() => buildCorrelationTree(records.value)); const failures = computed(() => records.value.filter(isFailure)); return { records, status, /** The run id currently being tailed (live) or viewed (history). */ currentRunId, /** The active source — set to "live" or a TelemetryRunMeta to switch. */ source, byCorrelation, tree, failures, recent: (kind?: string) => kind ? records.value.filter((r) => r.kind === kind) : records.value, connect, disconnect, clear, }; } /** * `useRunList` — reactive list of past telemetry runs for the given project. * Fetches once on mount; call `refresh()` to reload. */ export function useRunList( project?: MaybeRefOrGetter, options: { fetchFn?: typeof fetch } = {}, ) { const doFetch = options.fetchFn ?? ((...a: Parameters) => fetch(...a)); const runs = ref([]); const loading = ref(false); const error = ref(null); async function refresh(): Promise { loading.value = true; error.value = null; try { const proj = toValue(project); const url = proj ? `${RUNS_URL}?project=${encodeURIComponent(proj)}` : RUNS_URL; const res = await doFetch(url); if (!res.ok) { error.value = `Failed to load runs (${res.status})`; return; } const body = (await res.json()) as { runs?: TelemetryRunMeta[] }; runs.value = body.runs ?? []; } catch (err) { error.value = (err as Error).message; } finally { loading.value = false; } } watch(() => toValue(project), refresh, { immediate: true }); return { runs, loading, error, refresh }; }