/** * Waterfall layout — pure geometry for the Flow / Trace view. * * Takes a causation forest (`TraceNode[]` from `buildCorrelationTree`) and lays * it out as time-positioned rows: depth-indented, each with an offset + width as * a percentage of the trace span, plus the critical path (the longest-duration * root→leaf chain). No DOM, no Vue — unit-tested in isolation; the component * just renders the rows. */ import { isFailure, type TelemetryRecord, type TraceNode } from "./telemetry"; /** Smallest bar a row gets so zero-duration spans stay visible as a tick. */ const MIN_WIDTH_PCT = 1.5; /** One laid-out row of the waterfall. */ export interface WaterfallRow { readonly record: TelemetryRecord; /** Tree depth (0 = root) — drives the gutter indent. */ readonly depth: number; /** ms from the trace start to this row's start. */ readonly offsetMs: number; /** This row's own duration in ms (0 if instantaneous). */ readonly durationMs: number; /** Left edge as a percentage [0,100] of the trace span. */ readonly offsetPct: number; /** Bar width as a percentage of the trace span (clamped to a visible min). */ readonly widthPct: number; /** On the critical (longest-duration) path. */ readonly critical: boolean; /** A failure record (drives the red row treatment). */ readonly failed: boolean; /** Human label — handler/event name, falling back to the kind. */ readonly label: string; /** Stable key for `v-for` + selection. */ readonly key: string; } /** The full layout: rows in pre-order plus the resolved time window. */ export interface WaterfallLayout { readonly rows: WaterfallRow[]; readonly startMs: number; readonly endMs: number; readonly spanMs: number; } function clamp(n: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, n)); } function parseTs(r: TelemetryRecord): number | undefined { if (!r.ts) return undefined; const t = Date.parse(r.ts); return Number.isNaN(t) ? undefined : t; } /** The display name carried on a record, by convention, else the kind. */ export function recordLabel(r: TelemetryRecord): string { for (const field of ["name", "action", "event", "query", "actor", "workflow", "handler"]) { const v = r[field]; if (typeof v === "string" && v.length > 0) return v; } return r.kind; } interface Flat { readonly node: TraceNode; readonly depth: number; } function flatten(forest: readonly TraceNode[], depth = 0, out: Flat[] = []): Flat[] { for (const node of forest) { out.push({ node, depth }); flatten(node.children, depth + 1, out); } return out; } /** The longest downward (root→leaf) path by total duration, with its total. */ function bestPath(node: TraceNode): { total: number; nodes: TraceNode[] } { const self = node.record.durationMs ?? 0; if (node.children.length === 0) return { total: self, nodes: [node] }; let best = { total: -1, nodes: [] as TraceNode[] }; for (const child of node.children) { const p = bestPath(child); if (p.total > best.total) best = p; } return { total: self + best.total, nodes: [node, ...best.nodes] }; } /** * The critical path across a forest — the single root→leaf chain with the * greatest summed duration. Returns the set of nodes on that chain. */ export function criticalPath(forest: readonly TraceNode[]): Set { let best = { total: -1, nodes: [] as TraceNode[] }; for (const root of forest) { const p = bestPath(root); if (p.total > best.total) best = p; } return new Set(best.nodes); } /** * Lay a causation forest out as a waterfall. Rows are returned in pre-order * (parents before children). When records carry `ts`, bars are time-positioned * against the trace span; when none do, every bar spans full width so the tree * still reads (depth + kind + duration label) without a timeline. */ export function buildWaterfall(forest: readonly TraceNode[]): WaterfallLayout { const flat = flatten(forest); const crit = criticalPath(forest); const starts = flat .map((f) => parseTs(f.node.record)) .filter((t): t is number => t !== undefined); const haveTime = starts.length > 0; const startMs = haveTime ? Math.min(...starts) : 0; const endMs = haveTime ? Math.max( ...flat.map((f) => (parseTs(f.node.record) ?? startMs) + (f.node.record.durationMs ?? 0)), ) : 0; const spanMs = Math.max(endMs - startMs, 1); const rows: WaterfallRow[] = flat.map((f, i) => { const rec = f.node.record; const start = parseTs(rec) ?? startMs; const offsetMs = haveTime ? start - startMs : 0; const durationMs = rec.durationMs ?? 0; const offsetPct = haveTime ? clamp((offsetMs / spanMs) * 100, 0, 100) : 0; const rawWidth = haveTime ? (durationMs / spanMs) * 100 : 100; const widthPct = clamp(Math.max(rawWidth, MIN_WIDTH_PCT), MIN_WIDTH_PCT, 100 - offsetPct); return { record: rec, depth: f.depth, offsetMs, durationMs, offsetPct, widthPct, critical: crit.has(f.node), failed: isFailure(rec), label: recordLabel(rec), key: rec.envelope?.messageId ?? `${rec.kind}-${i}`, }; }); return { rows, startMs, endMs, spanMs }; }