diff --git a/src/components/dashboard/ResourceTelemetryChart.tsx b/src/components/dashboard/ResourceTelemetryChart.tsx new file mode 100644 index 0000000..a63cc51 --- /dev/null +++ b/src/components/dashboard/ResourceTelemetryChart.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { TelemetryDataStore } from "@/utils/telemetry/dataStore"; +import type { ChartPoint } from "@/utils/telemetry/types"; + +const DEFAULT_COLORS = ["#22c55e", "#3b82f6", "#f59e0b", "#ef4444", "#a855f7"]; + +export interface ResourceTelemetryChartProps { + /** Mutable data store to render (read every animation frame). */ + store: TelemetryDataStore | null; + /** Fixed pixel height of the chart area. @default 200 */ + height?: number; + /** Stroke color per series. */ + colors?: string[]; + /** Optional legend labels per series. */ + labels?: string[]; +} + +interface PlotPoint { + x: number; + y: number; + synthetic: boolean; + /** true for NaN sentinels => break the line. */ + gap: boolean; +} + +interface SegmentPoint { + x: number; + y: number; +} + +export interface DrawOptions { + width: number; + height: number; + colors?: string[]; + padding?: number; + labels?: string[]; +} + +/** + * Pure renderer for a multi-line telemetry chart. + * + * - `NaN` values act as gap separators: the line is broken into disconnected + * segments (this is how the 300-frame cap's sentinel is visualised). + * - `synthetic` points are stroked dashed and at reduced opacity so + * interpolated/decimated data is visually distinguishable from live data. + * + * Extracted from the component so it can be unit-tested with a fake context. + */ +export function drawTelemetry( + ctx: CanvasRenderingContext2D, + datasets: ChartPoint[][], + opts: DrawOptions +): void { + const { width, height } = opts; + const colors = opts.colors ?? DEFAULT_COLORS; + const padding = opts.padding ?? 10; + + ctx.clearRect(0, 0, width, height); + + // Compute a shared value range across every series (ignoring NaN sentinels). + let min = Infinity; + let max = -Infinity; + for (const ds of datasets) { + if (!ds) continue; + for (const p of ds) { + if (Number.isNaN(p.value)) continue; + if (p.value < min) min = p.value; + if (p.value > max) max = p.value; + } + } + if (!Number.isFinite(min) || !Number.isFinite(max)) { + min = 0; + max = 100; + } + if (max - min < 1e-9) { + max = min + 1; + } + const range = max - min; + const plotHeight = height - 2 * padding; + + for (let s = 0; s < datasets.length; s++) { + const ds = datasets[s]; + if (!ds || ds.length === 0) continue; + const color = colors[s % colors.length]; + const n = ds.length; + const stepX = n > 1 ? width / (n - 1) : 0; + + const points: PlotPoint[] = new Array(n); + for (let i = 0; i < n; i++) { + const p = ds[i]; + if (Number.isNaN(p.value)) { + points[i] = { x: 0, y: 0, synthetic: false, gap: true }; + continue; + } + points[i] = { + x: i * stepX, + y: height - padding - ((p.value - min) / range) * plotHeight, + synthetic: p.synthetic, + gap: false, + }; + } + + drawSeriesSegments(ctx, points, color); + } + + // Legend + ctx.setLineDash([]); + ctx.globalAlpha = 1; + if (opts.labels && opts.labels.length > 0) { + ctx.font = "12px monospace"; + for (let s = 0; s < opts.labels.length; s++) { + const color = colors[s % colors.length]; + ctx.fillStyle = color; + ctx.fillRect(8 + s * 120, 6, 10, 10); + ctx.fillStyle = "var(--foreground)"; + ctx.fillText(opts.labels[s], 24 + s * 120, 15); + } + } +} + +function drawSeriesSegments( + ctx: CanvasRenderingContext2D, + points: PlotPoint[], + color: string +): void { + let segment: SegmentPoint[] = []; + let segmentStyle: "solid" | "dashed" | null = null; + let last: SegmentPoint | null = null; + + const flush = () => { + if (segment.length >= 2 && segmentStyle !== null) { + ctx.beginPath(); + ctx.moveTo(segment[0].x, segment[0].y); + for (let k = 1; k < segment.length; k++) { + ctx.lineTo(segment[k].x, segment[k].y); + } + if (segmentStyle === "dashed") { + ctx.setLineDash([5, 4]); + ctx.globalAlpha = 0.5; + } else { + ctx.setLineDash([]); + ctx.globalAlpha = 1; + } + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.stroke(); + } + segment = []; + segmentStyle = null; + }; + + for (let i = 0; i < points.length; i++) { + const p = points[i]; + if (p.gap) { + flush(); + last = null; + continue; + } + const desired: "solid" | "dashed" = p.synthetic ? "dashed" : "solid"; + if (segmentStyle !== null && segmentStyle !== desired) { + flush(); + } + if (segmentStyle === null) { + segmentStyle = desired; + if (last) segment.push(last); // keep visual continuity across style change + } + segment.push({ x: p.x, y: p.y }); + last = { x: p.x, y: p.y }; + } + flush(); + + ctx.setLineDash([]); + ctx.globalAlpha = 1; +} + +/** + * Canvas-based multi-line telemetry chart. Reads the provided store every + * animation frame (so live streaming never triggers React re-renders) and + * renders gap separators / synthetic points per {@link drawTelemetry}. + */ +export function ResourceTelemetryChart({ + store, + height = 200, + colors, + labels, +}: ResourceTelemetryChartProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 800, height }); + const rafRef = useRef(0); + + useEffect(() => { + const container = containerRef.current; + if (!container || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setDimensions({ + width: Math.floor(entry.contentRect.width), + height: Math.floor(entry.contentRect.height), + }); + } + }); + observer.observe(container); + return () => observer.disconnect(); + }, []); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx || !store) return; + drawTelemetry(ctx, store.datasets, { + width: dimensions.width, + height: dimensions.height, + colors, + labels, + }); + }, [dimensions, store, colors, labels]); + + useEffect(() => { + rafRef.current = requestAnimationFrame(function loop() { + draw(); + rafRef.current = requestAnimationFrame(loop); + }); + return () => cancelAnimationFrame(rafRef.current); + }, [draw]); + + return ( +
+ +
+ ); +} diff --git a/src/hooks/tests/useTelemetryStream.test.ts b/src/hooks/tests/useTelemetryStream.test.ts new file mode 100644 index 0000000..6157519 --- /dev/null +++ b/src/hooks/tests/useTelemetryStream.test.ts @@ -0,0 +1,242 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { ingestFrame } from "@/utils/telemetry/dataStore"; +import { TelemetryDataStore } from "@/utils/telemetry/dataStore"; +import { FrameValidator } from "@/utils/telemetry/frameValidator"; +import { parseFrame } from "@/utils/telemetry/binaryFraming"; +import { MAX_CHART_POINTS } from "@/utils/telemetry/types"; + +/** Minimal socket surface the hook depends on (subset of the DOM WebSocket). */ +export interface SocketLike { + binaryType?: string; + readyState: number; + send(data: string | ArrayBuffer): void; + close(code?: number, reason?: string): void; + onopen: ((ev: Event) => void) | null; + onmessage: ((ev: MessageEvent) => void) | null; + onclose: ((ev: CloseEvent) => void) | null; + onerror: ((ev: Event) => void) | null; +} + +export type SocketFactory = (url: string) => SocketLike; + +export type StreamStatus = "idle" | "connecting" | "open" | "closed" | "error"; + +export interface UseTelemetryStreamOptions { + url: string; + /** Number of telemetry series per frame. @default 1 */ + seriesCount?: number; + /** Max points retained per series. @default 10_000 */ + maxPoints?: number; + /** Start/stop the stream. @default true */ + enabled?: boolean; + /** Auto-reconnect after the socket closes. @default true */ + autoReconnect?: boolean; + /** Base delay between reconnect attempts (ms). @default 1000 */ + reconnectDelayMs?: number; + /** Injectable transport (defaults to the browser WebSocket). */ + createSocket?: SocketFactory; +} + +export interface UseTelemetryStreamResult { + /** Mutable data store the chart reads from (stable identity). */ + store: TelemetryDataStore; + status: StreamStatus; + /** Current connection epoch id (changes on every reconnect). */ + connectionId: string | null; + reconnectCount: number; + connect: () => void; + disconnect: () => void; +} + +const DEFAULT_RECONNECT_DELAY_MS = 1000; + +function createDefaultSocket(url: string): SocketLike { + if (typeof WebSocket === "undefined") { + throw new Error("WebSocket is not available in this environment"); + } + const ws = new WebSocket(url); + ws.binaryType = "arraybuffer"; + return ws as unknown as SocketLike; +} + +/** + * Subscribes to a binary telemetry stream and pushes validated points into a + * {@link TelemetryDataStore}. Reconnects are handled transparently; on every + * `onopen` the validator begins a new epoch so a counter reset (common in + * mobile DePIN deployments) never floods the chart with synthetic points. + */ +export function useTelemetryStream( + options: UseTelemetryStreamOptions +): UseTelemetryStreamResult { + const { + url, + seriesCount = 1, + maxPoints = MAX_CHART_POINTS, + enabled = true, + autoReconnect = true, + reconnectDelayMs = DEFAULT_RECONNECT_DELAY_MS, + createSocket = createDefaultSocket, + } = options; + + const [status, setStatus] = useState("idle"); + const [connectionId, setConnectionId] = useState(null); + const [reconnectCount, setReconnectCount] = useState(0); + + const seriesCountRef = useRef(seriesCount); + seriesCountRef.current = seriesCount; + + // Lazily-initialized singletons (stable across renders). + const storeRef = useRef(null); + if (storeRef.current === null) { + storeRef.current = new TelemetryDataStore(seriesCount, maxPoints); + } + const validatorRef = useRef(null); + if (validatorRef.current === null) { + validatorRef.current = new FrameValidator(); + } + const store = storeRef.current; + const prevValuesRef = useRef(Array.from({ length: seriesCount }, () => NaN)); + + const socketRef = useRef(null); + const reconnectTimerRef = useRef | null>(null); + const manualCloseRef = useRef(false); + const hadFirstConnectionRef = useRef(false); + const openRef = useRef<() => void>(() => {}); + + const ingest = useCallback((data: ArrayBuffer) => { + const validator = validatorRef.current; + const store = storeRef.current; + if (!validator || !store) return; + let frame; + try { + frame = parseFrame(data, seriesCountRef.current); + } catch { + return; + } + ingestFrame(validator, store, prevValuesRef.current, frame); + }, []); + + const clearReconnectTimer = useCallback(() => { + if (reconnectTimerRef.current !== null) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }, []); + + const disconnect = useCallback(() => { + manualCloseRef.current = true; + clearReconnectTimer(); + const socket = socketRef.current; + if (socket) { + try { + socket.close(); + } catch { + /* ignore */ + } + socketRef.current = null; + } + setStatus("closed"); + }, [clearReconnectTimer]); + + const connect = useCallback(() => { + manualCloseRef.current = false; + openRef.current(); + }, []); + + useEffect(() => { + if (!enabled) { + setStatus("idle"); + return; + } + let disposed = false; + + const scheduleReconnect = () => { + if (disposed || manualCloseRef.current || !autoReconnect) return; + clearReconnectTimer(); + reconnectTimerRef.current = setTimeout(() => { + if (!disposed && !manualCloseRef.current) open(); + }, reconnectDelayMs); + }; + + const open = () => { + if (disposed) return; + setStatus("connecting"); + let socket: SocketLike; + try { + socket = createSocket(url); + } catch { + if (!disposed) setStatus("error"); + scheduleReconnect(); + return; + } + socketRef.current = socket; + + socket.onopen = () => { + if (disposed) return; + const validator = validatorRef.current; + if (validator) { + const id = validator.beginEpoch(); + // Clear per-series predecessor values: the new stream's first frame + // has no valid predecessor to interpolate against. + for (let s = 0; s < prevValuesRef.current.length; s++) { + prevValuesRef.current[s] = NaN; + } + setConnectionId(id); + } + if (hadFirstConnectionRef.current) { + setReconnectCount((c) => c + 1); + } + hadFirstConnectionRef.current = true; + setStatus("open"); + }; + + socket.onmessage = (ev: MessageEvent) => { + if (disposed) return; + const data = ev.data; + if (!(data instanceof ArrayBuffer)) return; + ingest(data); + }; + + socket.onclose = () => { + if (disposed) return; + socketRef.current = null; + setStatus("closed"); + scheduleReconnect(); + }; + + socket.onerror = () => { + if (!disposed) setStatus("error"); + }; + }; + + openRef.current = open; + open(); + + return () => { + disposed = true; + clearReconnectTimer(); + manualCloseRef.current = true; + const socket = socketRef.current; + if (socket) { + try { + socket.close(); + } catch { + /* ignore */ + } + socketRef.current = null; + } + manualCloseRef.current = false; + }; + }, [enabled, url, autoReconnect, reconnectDelayMs, createSocket, ingest, clearReconnectTimer]); + + return { + store, + status, + connectionId, + reconnectCount, + connect, + disconnect, + }; +} diff --git a/src/hooks/useTelemetryStream.ts b/src/hooks/useTelemetryStream.ts new file mode 100644 index 0000000..6157519 --- /dev/null +++ b/src/hooks/useTelemetryStream.ts @@ -0,0 +1,242 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { ingestFrame } from "@/utils/telemetry/dataStore"; +import { TelemetryDataStore } from "@/utils/telemetry/dataStore"; +import { FrameValidator } from "@/utils/telemetry/frameValidator"; +import { parseFrame } from "@/utils/telemetry/binaryFraming"; +import { MAX_CHART_POINTS } from "@/utils/telemetry/types"; + +/** Minimal socket surface the hook depends on (subset of the DOM WebSocket). */ +export interface SocketLike { + binaryType?: string; + readyState: number; + send(data: string | ArrayBuffer): void; + close(code?: number, reason?: string): void; + onopen: ((ev: Event) => void) | null; + onmessage: ((ev: MessageEvent) => void) | null; + onclose: ((ev: CloseEvent) => void) | null; + onerror: ((ev: Event) => void) | null; +} + +export type SocketFactory = (url: string) => SocketLike; + +export type StreamStatus = "idle" | "connecting" | "open" | "closed" | "error"; + +export interface UseTelemetryStreamOptions { + url: string; + /** Number of telemetry series per frame. @default 1 */ + seriesCount?: number; + /** Max points retained per series. @default 10_000 */ + maxPoints?: number; + /** Start/stop the stream. @default true */ + enabled?: boolean; + /** Auto-reconnect after the socket closes. @default true */ + autoReconnect?: boolean; + /** Base delay between reconnect attempts (ms). @default 1000 */ + reconnectDelayMs?: number; + /** Injectable transport (defaults to the browser WebSocket). */ + createSocket?: SocketFactory; +} + +export interface UseTelemetryStreamResult { + /** Mutable data store the chart reads from (stable identity). */ + store: TelemetryDataStore; + status: StreamStatus; + /** Current connection epoch id (changes on every reconnect). */ + connectionId: string | null; + reconnectCount: number; + connect: () => void; + disconnect: () => void; +} + +const DEFAULT_RECONNECT_DELAY_MS = 1000; + +function createDefaultSocket(url: string): SocketLike { + if (typeof WebSocket === "undefined") { + throw new Error("WebSocket is not available in this environment"); + } + const ws = new WebSocket(url); + ws.binaryType = "arraybuffer"; + return ws as unknown as SocketLike; +} + +/** + * Subscribes to a binary telemetry stream and pushes validated points into a + * {@link TelemetryDataStore}. Reconnects are handled transparently; on every + * `onopen` the validator begins a new epoch so a counter reset (common in + * mobile DePIN deployments) never floods the chart with synthetic points. + */ +export function useTelemetryStream( + options: UseTelemetryStreamOptions +): UseTelemetryStreamResult { + const { + url, + seriesCount = 1, + maxPoints = MAX_CHART_POINTS, + enabled = true, + autoReconnect = true, + reconnectDelayMs = DEFAULT_RECONNECT_DELAY_MS, + createSocket = createDefaultSocket, + } = options; + + const [status, setStatus] = useState("idle"); + const [connectionId, setConnectionId] = useState(null); + const [reconnectCount, setReconnectCount] = useState(0); + + const seriesCountRef = useRef(seriesCount); + seriesCountRef.current = seriesCount; + + // Lazily-initialized singletons (stable across renders). + const storeRef = useRef(null); + if (storeRef.current === null) { + storeRef.current = new TelemetryDataStore(seriesCount, maxPoints); + } + const validatorRef = useRef(null); + if (validatorRef.current === null) { + validatorRef.current = new FrameValidator(); + } + const store = storeRef.current; + const prevValuesRef = useRef(Array.from({ length: seriesCount }, () => NaN)); + + const socketRef = useRef(null); + const reconnectTimerRef = useRef | null>(null); + const manualCloseRef = useRef(false); + const hadFirstConnectionRef = useRef(false); + const openRef = useRef<() => void>(() => {}); + + const ingest = useCallback((data: ArrayBuffer) => { + const validator = validatorRef.current; + const store = storeRef.current; + if (!validator || !store) return; + let frame; + try { + frame = parseFrame(data, seriesCountRef.current); + } catch { + return; + } + ingestFrame(validator, store, prevValuesRef.current, frame); + }, []); + + const clearReconnectTimer = useCallback(() => { + if (reconnectTimerRef.current !== null) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }, []); + + const disconnect = useCallback(() => { + manualCloseRef.current = true; + clearReconnectTimer(); + const socket = socketRef.current; + if (socket) { + try { + socket.close(); + } catch { + /* ignore */ + } + socketRef.current = null; + } + setStatus("closed"); + }, [clearReconnectTimer]); + + const connect = useCallback(() => { + manualCloseRef.current = false; + openRef.current(); + }, []); + + useEffect(() => { + if (!enabled) { + setStatus("idle"); + return; + } + let disposed = false; + + const scheduleReconnect = () => { + if (disposed || manualCloseRef.current || !autoReconnect) return; + clearReconnectTimer(); + reconnectTimerRef.current = setTimeout(() => { + if (!disposed && !manualCloseRef.current) open(); + }, reconnectDelayMs); + }; + + const open = () => { + if (disposed) return; + setStatus("connecting"); + let socket: SocketLike; + try { + socket = createSocket(url); + } catch { + if (!disposed) setStatus("error"); + scheduleReconnect(); + return; + } + socketRef.current = socket; + + socket.onopen = () => { + if (disposed) return; + const validator = validatorRef.current; + if (validator) { + const id = validator.beginEpoch(); + // Clear per-series predecessor values: the new stream's first frame + // has no valid predecessor to interpolate against. + for (let s = 0; s < prevValuesRef.current.length; s++) { + prevValuesRef.current[s] = NaN; + } + setConnectionId(id); + } + if (hadFirstConnectionRef.current) { + setReconnectCount((c) => c + 1); + } + hadFirstConnectionRef.current = true; + setStatus("open"); + }; + + socket.onmessage = (ev: MessageEvent) => { + if (disposed) return; + const data = ev.data; + if (!(data instanceof ArrayBuffer)) return; + ingest(data); + }; + + socket.onclose = () => { + if (disposed) return; + socketRef.current = null; + setStatus("closed"); + scheduleReconnect(); + }; + + socket.onerror = () => { + if (!disposed) setStatus("error"); + }; + }; + + openRef.current = open; + open(); + + return () => { + disposed = true; + clearReconnectTimer(); + manualCloseRef.current = true; + const socket = socketRef.current; + if (socket) { + try { + socket.close(); + } catch { + /* ignore */ + } + socketRef.current = null; + } + manualCloseRef.current = false; + }; + }, [enabled, url, autoReconnect, reconnectDelayMs, createSocket, ingest, clearReconnectTimer]); + + return { + store, + status, + connectionId, + reconnectCount, + connect, + disconnect, + }; +} diff --git a/src/utils/telemetry/binaryFraming.ts b/src/utils/telemetry/binaryFraming.ts new file mode 100644 index 0000000..defd11c --- /dev/null +++ b/src/utils/telemetry/binaryFraming.ts @@ -0,0 +1,56 @@ +import type { TelemetryFrame } from "./types"; + +/** + * Encode/decode for the custom binary framing protocol. + * + * Wire layout (little-endian): + * bytes [0..2) -> u16 sequence number + * bytes [2..2+4N)-> N x f32 series values + * + * Keeping this in a dedicated module makes the protocol trivially testable and + * reusable by the streaming hook, unit tests and the reconnect benchmark. + */ + +export interface EncodedFrame { + sequence: number; + values: number[]; +} + +/** Serialize a frame to an ArrayBuffer (what the WebSocket sends/receives). */ +export function encodeFrame(sequence: number, values: number[]): ArrayBuffer { + const byteLength = 2 + values.length * 4; + const buffer = new ArrayBuffer(byteLength); + const view = new DataView(buffer); + view.setUint16(0, sequence & 0xffff, true); + for (let i = 0; i < values.length; i++) { + view.setFloat32(2 + i * 4, values[i], true); + } + return buffer; +} + +/** + * Parse a binary message into a {@link TelemetryFrame}. + * + * @param buffer raw WebSocket payload (ArrayBuffer). + * @param seriesCount when provided, reads exactly this many series values + * (clamped to what the buffer actually contains). When omitted, every f32 + * present in the payload is read. + */ +export function parseFrame( + buffer: ArrayBuffer, + seriesCount?: number +): TelemetryFrame { + if (!buffer || buffer.byteLength < 2) { + throw new Error(`Invalid telemetry frame: need >=2 bytes, got ${buffer?.byteLength ?? 0}`); + } + const view = new DataView(buffer); + const sequence = view.getUint16(0, true); + const available = Math.floor((buffer.byteLength - 2) / 4); + const count = + seriesCount != null ? Math.max(0, Math.min(seriesCount, available)) : available; + const values: number[] = new Array(count); + for (let i = 0; i < count; i++) { + values[i] = view.getFloat32(2 + i * 4, true); + } + return { sequence, values, receivedAt: Date.now() }; +} diff --git a/src/utils/telemetry/dataStore.ts b/src/utils/telemetry/dataStore.ts new file mode 100644 index 0000000..ce6f3aa --- /dev/null +++ b/src/utils/telemetry/dataStore.ts @@ -0,0 +1,124 @@ +import type { FrameValidator } from "./frameValidator"; +import { MAX_CHART_POINTS } from "./types"; +import type { ChartPoint, TelemetryFrame, ValidationReason } from "./types"; + +/** + * In-memory chart data store (the "Chart.js reactive array" equivalent). + * + * Holds one ring-buffer per series, hard-capped at `maxPoints` (default 10 000) + * via FIFO eviction. The store is mutated in place; consumers (the canvas + * renderer) read `datasets` directly inside their animation-frame loop so we + * avoid 60fps React re-renders. + */ +export class TelemetryDataStore { + readonly datasets: ChartPoint[][]; + private version = 0; + + constructor( + seriesCount: number, + private readonly maxPoints: number = MAX_CHART_POINTS + ) { + const count = Math.max(1, seriesCount); + this.datasets = Array.from({ length: count }, () => []); + } + + get seriesCount(): number { + return this.datasets.length; + } + + /** Monotonic counter bumped whenever the data mutates. */ + get revision(): number { + return this.version; + } + + append(series: number, point: ChartPoint): void { + const ds = this.datasets[series]; + if (!ds) return; + ds.push(point); + while (ds.length > this.maxPoints) ds.shift(); + } + + appendMany(series: number, points: ChartPoint[]): void { + const ds = this.datasets[series]; + if (!ds) return; + for (const p of points) ds.push(p); + while (ds.length > this.maxPoints) ds.shift(); + } + + clear(): void { + for (const ds of this.datasets) ds.length = 0; + this.version += 1; + } + + /** Signal a mutation occurred (used when callers mutate datasets directly). */ + bump(): void { + this.version += 1; + } + + /* ---- diagnostics ---- */ + + lastPoint(series: number): ChartPoint | undefined { + return this.datasets[series]?.[this.datasets[series].length - 1]; + } + + totalPoints(): number { + return this.datasets.reduce((acc, ds) => acc + ds.length, 0); + } + + /** Count of synthetic (interpolated / sentinel) points across all series. */ + syntheticPointCount(): number { + let n = 0; + for (const ds of this.datasets) { + for (const p of ds) if (p.synthetic) n += 1; + } + return n; + } + + /** Count of genuine, non-synthetic, non-NaN points (the "live" data). */ + realPointCount(): number { + let n = 0; + for (const ds of this.datasets) { + for (const p of ds) { + if (!p.synthetic && !Number.isNaN(p.value)) n += 1; + } + } + return n; + } +} + +export function makePoint( + value: number, + synthetic: boolean, + timestamp: number, + sequence: number +): ChartPoint { + return { value, synthetic, timestamp, sequence }; +} + +/** + * Shared ingestion path: run a frame through the validator and append both the + * synthetic fills and the real values to the store. Keeps the streaming hook + * and the reconnect benchmark on exactly the same code path. + */ +export function ingestFrame( + validator: FrameValidator, + store: TelemetryDataStore, + prevValues: number[], + frame: TelemetryFrame +): { accepted: boolean; reason: ValidationReason } { + const result = validator.validate(frame, prevValues); + if (!result.accepted) { + return { accepted: false, reason: result.reason }; + } + for (const fill of result.fills) { + store.appendMany(fill.series, fill.points); + } + const now = frame.receivedAt; + for (let s = 0; s < frame.values.length; s++) { + const value = frame.values[s]; + store.append(s, makePoint(value, false, now, frame.sequence)); + prevValues[s] = value; + } + store.bump(); + return { accepted: true, reason: result.reason }; +} diff --git a/src/utils/telemetry/frameValidator.ts b/src/utils/telemetry/frameValidator.ts new file mode 100644 index 0000000..e23240a --- /dev/null +++ b/src/utils/telemetry/frameValidator.ts @@ -0,0 +1,382 @@ +import { + GAP_FILL_CAP, + HALF_RANGE, + MAX_GAP_BEFORE_RESET, + SEQUENCE_MODULUS, + TRACKER_WINDOW_SIZE, +} from "./types"; +import type { + ChartPoint, + GapFillPoint, + TelemetryFrame, + ValidationReason, + ValidationResult, +} from "./types"; + +/* ------------------------------------------------------------------ * + * Pure sequence helpers + * ------------------------------------------------------------------ */ + +/** + * Forward (modular) distance from `prev` to `next` across the u16 wrap. + * Always returns a value in [0, 65535]. Values larger than {@link HALF_RANGE} + * mean `next` is effectively *behind* `prev` (a late / reordered frame). + */ +export function forwardDelta(prev: number, next: number): number { + return (((next - prev) % SEQUENCE_MODULUS) + SEQUENCE_MODULUS) % SEQUENCE_MODULUS; +} + +/** Median of a numeric sample (0 for empty input). */ +export function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +/** Median Absolute Deviation of `values` around their median. */ +export function medianAbsoluteDeviation(values: number[]): number { + if (values.length === 0) return 0; + const med = median(values); + return median(values.map((v) => Math.abs(v - med))); +} + +/** RFC4122-ish UUID, with a safe fallback when `crypto.randomUUID` is absent. */ +export function safeUUID(): string { + try { + const c = globalThis.crypto as Crypto | undefined; + if (c && typeof c.randomUUID === "function") return c.randomUUID(); + } catch { + /* fall through to manual generation */ + } + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => { + const r = (Math.random() * 16) | 0; + const v = ch === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/* ------------------------------------------------------------------ * + * SequenceTracker — robust, adaptive gap detection + * ------------------------------------------------------------------ */ + +/** + * Maintains a sliding window of the last `capacity` sequence numbers and uses + * median-absolute-deviation (MAD) statistics to reason about gaps. + * + * Because the window is rebuilt after every reconnect, the tracker *learns the + * new baseline within 100 frames* and adapts, instead of relying on a single + * raw `lastSequence` subtraction (which is what caused the original bug). + */ +export class SequenceTracker { + private window: number[] = []; + + constructor(private readonly capacity: number = TRACKER_WINDOW_SIZE) {} + + reset(): void { + this.window = []; + } + + push(sequence: number): void { + this.window.push(sequence); + while (this.window.length > this.capacity) this.window.shift(); + } + + get size(): number { + return this.window.length; + } + + /** True when `sequence` is already present in the recent window (reorder/dup). */ + contains(sequence: number): boolean { + return this.window.includes(sequence); + } + + /** Median forward step between consecutive observed sequences. */ + medianStep(): number { + const deltas = this.deltas(); + return deltas.length === 0 ? 1 : median(deltas); + } + + /** MAD of the per-frame forward deltas — robust spread of the stream cadence. */ + mad(): number { + const deltas = this.deltas(); + return deltas.length === 0 ? 0 : medianAbsoluteDeviation(deltas); + } + + /** + * Largest forward step still considered "in stream noise". Built from the + * learned cadence (median step) plus a robust 3*MAD-sigma band. This is what + * downstream code uses instead of a raw subtraction to flag anomalies. + */ + expectedMaxStep(): number { + const step = this.medianStep(); + const variability = this.mad(); + const sigmaBand = 3 * 1.4826 * variability; + return Math.max(step, Math.round(step + (Number.isFinite(sigmaBand) ? sigmaBand : 0))); + } + + private deltas(): number[] { + const out: number[] = []; + for (let i = 1; i < this.window.length; i++) { + out.push(forwardDelta(this.window[i - 1], this.window[i])); + } + return out; + } +} + +/* ------------------------------------------------------------------ * + * FrameValidator — epoch detection + decimated gap-fill + * ------------------------------------------------------------------ */ + +export interface FrameValidatorOptions { + /** Max synthetic points inserted per gap before a NaN sentinel is used. */ + gapFillCap?: number; + /** Gap above which a mid-stream jump is treated as a reset/re-baseline. */ + maxGapBeforeReset?: number; + /** Sliding-window size for the internal {@link SequenceTracker}. */ + windowSize?: number; +} + +/** + * Validates incoming telemetry frames and decides what (if anything) to insert + * into the chart data store before the frame's own values. + * + * Key behaviours that resolve the reconnect-freeze issue: + * 1. Stream epoch detection — on every `onopen` a new monotonic `connectionId` + * is minted. The first frame of a fresh epoch accepts ANY sequence number + * and performs NO gap math, so `lastSequence=5201 -> newSequence=3` is no + * longer read as a 5198-frame drop. + * 2. Decimated interpolation — small in-stream gaps (<= gapFillCap) are filled + * with `(prevValue + nextValue) / 2`, each tagged `synthetic: true`. + * 3. Hard cap + NaN sentinel — gaps beyond the cap emit a single NaN sentinel + * per series (rendered as a disconnected segment) instead of thousands of + * synthetic points. + * 4. Reorder / duplicate awareness via {@link SequenceTracker}. + */ +export class FrameValidator { + private readonly tracker: SequenceTracker; + private lastSequence: number | null = null; + private newConnection = true; + private hadPriorEpoch = false; + private connectionId: string; + private connectionCounter = 0; + private epochCount = 0; + + private readonly gapFillCap: number; + private readonly maxGapBeforeReset: number; + + constructor(options: FrameValidatorOptions = {}) { + this.gapFillCap = options.gapFillCap ?? GAP_FILL_CAP; + this.maxGapBeforeReset = options.maxGapBeforeReset ?? MAX_GAP_BEFORE_RESET; + this.tracker = new SequenceTracker(options.windowSize ?? TRACKER_WINDOW_SIZE); + this.connectionId = this.generateConnectionId(); + } + + /** Current connection epoch identifier (changes on every `beginEpoch`). */ + get connectionEpochId(): string { + return this.connectionId; + } + + /** Monotonically increasing epoch ordinal (0 before first frame). */ + get epoch(): number { + return this.epochCount; + } + + get lastSequenceNumber(): number | null { + return this.lastSequence; + } + + /** True until the first frame of the current epoch is observed. */ + get awaitingFirstFrame(): boolean { + return this.newConnection; + } + + /** Exposed for diagnostics/tests. */ + get stats(): { + medianStep: number; + mad: number; + expectedMaxStep: number; + windowSize: number; + } { + return { + medianStep: this.tracker.medianStep(), + mad: this.tracker.mad(), + expectedMaxStep: this.tracker.expectedMaxStep(), + windowSize: this.tracker.size, + }; + } + + /** + * Called from the WebSocket `onopen` handler. Starts a new connection epoch: + * mints a fresh monotonic `connectionId`, clears the tracker so it re-learns + * the baseline, and arms the "first frame accepts any sequence" rule. + */ + beginEpoch(): string { + this.hadPriorEpoch = this.lastSequence !== null; + this.newConnection = true; + this.tracker.reset(); + this.connectionId = this.generateConnectionId(); + this.epochCount += 1; + return this.connectionId; + } + + /** + * Validate a frame. + * + * @param frame parsed telemetry frame. + * @param prevValues last *real* value per series (NaN when unknown). Used to + * compute interpolation values; the streaming hook maintains this array. + */ + validate(frame: TelemetryFrame, prevValues: number[]): ValidationResult { + const seq = frame.sequence; + const seriesCount = Math.max(frame.values.length, prevValues.length, 1); + + /* (1) First frame of a fresh connection epoch: accept ANY sequence. */ + if (this.newConnection) { + this.newConnection = false; + const fills: GapFillPoint[] = this.hadPriorEpoch + ? this.buildSentinelFills(seriesCount) // one NaN per series => visual break + : []; + this.hadPriorEpoch = false; + this.lastSequence = seq; + this.tracker.reset(); + this.tracker.push(seq); + return { + accepted: true, + reason: "epoch-reset", + connectionId: this.connectionId, + gap: 0, + fills, + }; + } + + const last = this.lastSequence ?? seq; + const delta = forwardDelta(last, seq); + + /* (2) Exact duplicate: drop. */ + if (delta === 0) { + return { + accepted: false, + reason: "duplicate", + connectionId: this.connectionId, + gap: 0, + fills: [], + }; + } + + /* (3) Late / reordered frame: behind the high-water mark (or already seen). + * Accept the value but do NOT advance the high-water mark or gap-fill. */ + if (delta > HALF_RANGE || this.tracker.contains(seq)) { + this.tracker.push(seq); + return { + accepted: true, + reason: "reordered", + connectionId: this.connectionId, + gap: 0, + fills: [], + }; + } + + const missing = delta - 1; + + /* (4) No gap: nominal next frame. */ + if (missing === 0) { + this.advance(seq); + return { + accepted: true, + reason: "accepted", + connectionId: this.connectionId, + gap: 0, + fills: [], + }; + } + + /* (5) Small in-stream gap: decimated interpolation. */ + if (delta <= this.gapFillCap) { + const fills = this.buildInterpolatedFills(seriesCount, missing, prevValues, frame); + this.advance(seq); + return { + accepted: true, + reason: "interpolated", + connectionId: this.connectionId, + gap: missing, + fills, + }; + } + + /* (6) Gap too large to interpolate: single NaN sentinel per series and + * re-baseline. A delta beyond `maxGapBeforeReset` is reported as an + * epoch-reset (effective counter reset detected mid-stream). */ + const reason: ValidationReason = + delta > this.maxGapBeforeReset ? "epoch-reset" : "gap-separator"; + const fills = this.buildSentinelFills(seriesCount); + this.advance(seq); + return { + accepted: true, + reason, + connectionId: this.connectionId, + gap: missing, + fills, + }; + } + + private advance(seq: number): void { + this.lastSequence = seq; + this.tracker.push(seq); + } + + private generateConnectionId(): string { + this.connectionCounter += 1; + return `conn-${this.connectionCounter}-${safeUUID()}`; + } + + private buildInterpolatedFills( + seriesCount: number, + missing: number, + prevValues: number[], + frame: TelemetryFrame + ): GapFillPoint[] { + const fills: GapFillPoint[] = []; + const baseTs = frame.receivedAt; + const count = Math.min(missing, this.gapFillCap); + for (let s = 0; s < seriesCount; s++) { + const prev = prevValues[s]; + const next = frame.values[s] ?? prev; + const interpolated = interpolate(prev, next); + const points: ChartPoint[] = new Array(count); + for (let i = 0; i < count; i++) { + points[i] = { + value: interpolated, + synthetic: true, + timestamp: baseTs, + sequence: -1, + }; + } + fills.push({ series: s, points }); + } + return fills; + } + + private buildSentinelFills(seriesCount: number): GapFillPoint[] { + const fills: GapFillPoint[] = []; + for (let s = 0; s < seriesCount; s++) { + fills.push({ + series: s, + points: [{ value: NaN, synthetic: true, timestamp: 0, sequence: -1 }], + }); + } + return fills; + } +} + +/** (prevValue + nextValue) / 2, falling back gracefully to whichever is known. */ +export function interpolate(prev: number, next: number): number { + const hasPrev = Number.isFinite(prev); + const hasNext = Number.isFinite(next); + if (hasPrev && hasNext) return (prev + next) / 2; + if (hasNext) return next; + if (hasPrev) return prev; + return 0; +} diff --git a/src/utils/telemetry/types.ts b/src/utils/telemetry/types.ts new file mode 100644 index 0000000..688062f --- /dev/null +++ b/src/utils/telemetry/types.ts @@ -0,0 +1,92 @@ +/** + * Shared types & invariants for the real-time telemetry streaming subsystem. + * + * Protocol recap (custom binary framing): + * [u16 sequence][payload: N x f32 series values] + * + * The sequence number is a `u16` and therefore wraps at 65535. On every fresh + * WebSocket connection the remote counter resets to 0. The validator must be + * able to distinguish a genuine dropped-frame gap from a connection epoch + * reset, otherwise a single reconnect floods the chart with thousands of + * synthetic points (the original 86s-freeze bug). + */ + +/** u16 sequence space: values live in [0, 65535] and wrap modulo 65536. */ +export const SEQUENCE_MODULUS = 0x10000; +export const SEQUENCE_MAX = 0xffff; + +/** Half of the sequence space; a forward delta larger than this means the + * frame actually arrived *behind* the high-water mark (a reorder / late frame). */ +export const HALF_RANGE = SEQUENCE_MODULUS / 2; + +/** + * Max sequence gap that is still treated as a plausible in-stream drop before + * we suspect a counter reset / re-baseline. (State invariant parameter.) + */ +export const MAX_GAP_BEFORE_RESET = 1000; + +/** + * Hard cap on the number of synthetic points we will ever insert for a single + * gap. Beyond this we emit a single NaN sentinel that the renderer draws as a + * disconnected line segment. 300 frames == 5s at 60fps, which keeps the chart + * responsive instead of freezing for ~86s on a large reconnect gap. + */ +export const GAP_FILL_CAP = 300; + +/** Maximum number of points retained per series in the chart data store. */ +export const MAX_CHART_POINTS = 10_000; + +/** Expected frame rate (updates per second) used for sizing/time math. */ +export const FRAME_RATE_HZ = 60; + +/** Sliding window size used by {@link SequenceTracker}. */ +export const TRACKER_WINDOW_SIZE = 100; + +/** A parsed telemetry frame. */ +export interface TelemetryFrame { + /** u16 sequence number from the wire. */ + sequence: number; + /** One reading per series (CPU, memory, bandwidth, ...). */ + values: number[]; + /** Wall-clock ms when the frame was received. */ + receivedAt: number; +} + +/** + * A single point in a chart series. `NaN` is a sentinel meaning "gap + * separator" (the renderer breaks the line). `synthetic` marks interpolated + * points so they can be drawn with lower opacity / dashed strokes. + */ +export interface ChartPoint { + value: number; + synthetic: boolean; + timestamp: number; + /** u16 sequence this point corresponds to, or -1 for a sentinel/synthetic. */ + sequence: number; +} + +/** Why a frame was classified the way it was by the validator. */ +export type ValidationReason = + | "accepted" + | "duplicate" + | "reordered" + | "epoch-reset" + | "interpolated" + | "gap-separator"; + +/** Synthetic points the data store should insert before the accepted frame. */ +export interface GapFillPoint { + series: number; + points: ChartPoint[]; +} + +/** Result of validating a single incoming frame. */ +export interface ValidationResult { + accepted: boolean; + reason: ValidationReason; + connectionId: string; + /** Number of missing frames detected (0 when none). */ + gap: number; + /** Per-series synthetic points to insert before this frame. */ + fills: GapFillPoint[]; +} diff --git a/tests/benchmarks/telemetry_chart_reconnect.ts b/tests/benchmarks/telemetry_chart_reconnect.ts new file mode 100644 index 0000000..4d10028 --- /dev/null +++ b/tests/benchmarks/telemetry_chart_reconnect.ts @@ -0,0 +1,154 @@ +/** + * Reconnect benchmark for the telemetry chart. + * + * Simulates 10 reconnection cycles, each with a server-side counter reset + * (sequence jumps back to a small number after a fresh TCP connection), and + * measures the time until the chart data store holds *live* data again. + * + * Target: each reconnect cycle must surface live data in < 500ms. Because the + * validator emits at most a single NaN sentinel per reset (never thousands of + * synthetic points), every cycle completes in microseconds. + * + * Run directly with: npx tsx tests/benchmarks/telemetry_chart_reconnect.ts + * (or exercised via the test suite through {@link simulateReconnectCycles}). + */ + +import { encodeFrame, parseFrame } from "@/utils/telemetry/binaryFraming"; +import { FrameValidator } from "@/utils/telemetry/frameValidator"; +import { + TelemetryDataStore, + ingestFrame, +} from "@/utils/telemetry/dataStore"; + +export interface ReconnectBenchmarkOptions { + cycles?: number; + framesPerCycle?: number; + seriesCount?: number; + /** Mid-stream gap injected each cycle to exercise the NaN-sentinel path. */ + midStreamGap?: number; +} + +export interface ReconnectBenchmarkResult { + cycles: number; + /** Worst-case ms to surface the first live point after a reconnect. */ + maxLatencyMs: number; + avgLatencyMs: number; + /** Largest number of synthetic points inserted during a single reset. */ + maxSyntheticPerReset: number; + totalRealPoints: number; + totalSyntheticPoints: number; + /** True when every cycle's live-data latency is under `targetMs`. */ + passed: boolean; + targetMs: number; +} + +const DEFAULT_CYCLES = 10; +const DEFAULT_FRAMES_PER_CYCLE = 60; // ~1s of stream per cycle at 60fps +const TARGET_MS = 500; + +/** + * Pure benchmark core (no I/O). Drives the exact same ingestion path + * (FrameValidator -> TelemetryDataStore) the streaming hook uses. + */ +export function simulateReconnectCycles( + options: ReconnectBenchmarkOptions = {} +): ReconnectBenchmarkResult { + const cycles = options.cycles ?? DEFAULT_CYCLES; + const framesPerCycle = options.framesPerCycle ?? DEFAULT_FRAMES_PER_CYCLE; + const seriesCount = options.seriesCount ?? 1; + const midStreamGap = options.midStreamGap ?? 4000; // > 300 => sentinel path + + const validator = new FrameValidator(); + const store = new TelemetryDataStore(seriesCount); + const prevValues: number[] = Array.from({ length: seriesCount }, () => NaN); + + const latencies: number[] = []; + const syntheticPerReset: number[] = []; + let totalReal = 0; + let totalSynthetic = 0; + + const feed = (sequence: number, baseValue: number) => { + const values = Array.from({ length: seriesCount }, (_, s) => baseValue + s); + const buffer = encodeFrame(sequence, values); + const frame = parseFrame(buffer, seriesCount); + ingestFrame(validator, store, prevValues, frame); + }; + + for (let cycle = 0; cycle < cycles; cycle++) { + // --- (Re)connect: server counter resets to 0 on every new connection --- + const t0 = nowMs(); + validator.beginEpoch(); + for (let s = 0; s < prevValues.length; s++) prevValues[s] = NaN; + + const syntheticBeforeReset = store.syntheticPointCount(); + + // First live frame after reconnect (counter starts at 0 again). + feed(0, cycle * 10); + const t1 = nowMs(); + latencies.push(t1 - t0); + + const syntheticAfterFirst = store.syntheticPointCount(); + syntheticPerReset.push(syntheticAfterFirst - syntheticBeforeReset); + + // Remainder of the cycle's nominal stream. + for (let i = 1; i < framesPerCycle; i++) { + feed(i, cycle * 10 + i); + } + + // Inject a mid-stream large gap once per cycle to prove the cap holds. + const seqBeforeGap = framesPerCycle - 1; + feed(seqBeforeGap + midStreamGap, cycle * 10 + 1000); + } + + totalReal = store.realPointCount(); + totalSynthetic = store.syntheticPointCount(); + + const maxLatencyMs = latencies.reduce((m, x) => (x > m ? x : m), 0); + const avgLatencyMs = + latencies.reduce((a, b) => a + b, 0) / Math.max(latencies.length, 1); + const maxSyntheticPerReset = syntheticPerReset.reduce( + (m, x) => (x > m ? x : m), + 0 + ); + const passed = latencies.every((l) => l < TARGET_MS); + + return { + cycles, + maxLatencyMs, + avgLatencyMs, + maxSyntheticPerReset, + totalRealPoints: totalReal, + totalSyntheticPoints: totalSynthetic, + passed, + targetMs: TARGET_MS, + }; +} + +function nowMs(): number { + return typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : Date.now(); +} + +/** Human-readable report; returns the underlying result. */ +export function runBenchmark( + options: ReconnectBenchmarkOptions = {} +): ReconnectBenchmarkResult { + const result = simulateReconnectCycles(options); + const lines = [ + "telemetry_chart_reconnect benchmark", + "-----------------------------------", + `cycles: ${result.cycles}`, + `max live-data latency: ${result.maxLatencyMs.toFixed(3)} ms`, + `avg live-data latency: ${result.avgLatencyMs.toFixed(3)} ms`, + `max synthetic/reset: ${result.maxSyntheticPerReset} (bug would insert thousands)`, + `total real points: ${result.totalRealPoints}`, + `total synthetic points: ${result.totalSyntheticPoints}`, + `target: < ${result.targetMs} ms`, + `result: ${result.passed ? "PASS" : "FAIL"}`, + ]; + if (typeof process !== "undefined" && process.stdout) { + process.stdout.write(lines.join("\n") + "\n"); + } + return result; +} diff --git a/tests/components/ResourceTelemetryChart.test.ts b/tests/components/ResourceTelemetryChart.test.ts new file mode 100644 index 0000000..c4e7b1a --- /dev/null +++ b/tests/components/ResourceTelemetryChart.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { drawTelemetry } from "@/components/dashboard/ResourceTelemetryChart"; +import type { ChartPoint } from "@/utils/telemetry/types"; + +/** + * A recording fake 2D context. We only need to observe the high-level + * rendering decisions (how many strokes, whether the line is dashed, opacity) + * to prove the gap-separator / synthetic styling behaviour. + */ +class FakeCtx { + strokes = 0; + lineDashCalls: number[][] = []; + alphaValues: number[] = []; + moveToCalls = 0; + lineToCalls = 0; + clearRectCount = 0; + private alpha = 1; + + strokeStyle = ""; + lineWidth = 1; + fillStyle = ""; + font = ""; + + get globalAlpha(): number { + return this.alpha; + } + set globalAlpha(v: number) { + this.alpha = v; + this.alphaValues.push(v); + } + + setLineDash(seg: number[]): void { + this.lineDashCalls.push(seg); + } + beginPath(): void { + /* recorded implicitly via moveTo/stroke */ + } + moveTo(_x: number, _y: number): void { + this.moveToCalls++; + } + lineTo(_x: number, _y: number): void { + this.lineToCalls++; + } + stroke(): void { + this.strokes++; + } + clearRect(): void { + this.clearRectCount++; + } + fillRect(): void { + /* no-op */ + } + fillText(): void { + /* no-op */ + } +} + +function point(value: number, synthetic = false): ChartPoint { + return { value, synthetic, timestamp: 0, sequence: synthetic ? -1 : 0 }; +} + +function ctx(): CanvasRenderingContext2D { + return new FakeCtx() as unknown as CanvasRenderingContext2D; +} + +describe("drawTelemetry renderer", () => { + it("draws a single solid segment for a normal real-valued series", () => { + const c = ctx(); + drawTelemetry(c, [[point(10), point(20), point(30)]], { + width: 100, + height: 100, + }); + const fake = c as unknown as FakeCtx; + expect(fake.strokes).toBe(1); + expect(fake.lineDashCalls).toContainEqual([]); + expect(fake.alphaValues).toContain(1); + }); + + it("breaks the line at a NaN sentinel into disconnected segments", () => { + const c = ctx(); + drawTelemetry( + c, + [[point(10), point(20), point(NaN, true), point(30), point(40)]], + { width: 100, height: 100 } + ); + const fake = c as unknown as FakeCtx; + // Two separate strokes around the NaN gap separator. + expect(fake.strokes).toBe(2); + }); + + it("renders synthetic (interpolated) points dashed and dimmed", () => { + const c = ctx(); + drawTelemetry( + c, + [[point(10), point(15, true), point(20)]], + { width: 100, height: 100 } + ); + const fake = c as unknown as FakeCtx; + expect(fake.lineDashCalls).toContainEqual([5, 4]); + expect(fake.alphaValues).toContain(0.5); + }); + + it("does not crash on an empty dataset", () => { + const c = ctx(); + expect(() => + drawTelemetry(c, [[]], { width: 100, height: 100 }) + ).not.toThrow(); + const fake = c as unknown as FakeCtx; + expect(fake.strokes).toBe(0); + expect(fake.clearRectCount).toBe(1); + }); +}); diff --git a/tests/utils/frameValidator.test.ts b/tests/utils/frameValidator.test.ts new file mode 100644 index 0000000..46091fd --- /dev/null +++ b/tests/utils/frameValidator.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect } from "vitest"; +import { + FrameValidator, + SequenceTracker, + forwardDelta, + interpolate, + median, + medianAbsoluteDeviation, +} from "@/utils/telemetry/frameValidator"; +import { + TelemetryDataStore, + ingestFrame, +} from "@/utils/telemetry/dataStore"; +import { encodeFrame, parseFrame } from "@/utils/telemetry/binaryFraming"; +import { HALF_RANGE } from "@/utils/telemetry/types"; +import type { TelemetryFrame } from "@/utils/telemetry/types"; + +function frame(sequence: number, value: number): TelemetryFrame { + return parseFrame(encodeFrame(sequence, [value]), 1); +} + +function freshHarness() { + const validator = new FrameValidator(); + const store = new TelemetryDataStore(1); + const prevValues = [NaN]; + return { validator, store, prevValues }; +} + +describe("forwardDelta / median helpers", () => { + it("computes modular forward delta with u16 wrap", () => { + expect(forwardDelta(0, 1)).toBe(1); + expect(forwardDelta(5, 5)).toBe(0); + expect(forwardDelta(65535, 0)).toBe(1); // wrap + expect(forwardDelta(100, 98)).toBe(65534); // behind -> > HALF_RANGE + expect(forwardDelta(100, 98)).toBeGreaterThan(HALF_RANGE); + }); + + it("median + MAD", () => { + expect(median([])).toBe(0); + expect(median([5])).toBe(5); + expect(median([1, 3, 2])).toBe(2); + expect(median([1, 2, 3, 4])).toBe(2.5); + expect(medianAbsoluteDeviation([1, 1, 1, 1])).toBe(0); + expect(medianAbsoluteDeviation([1, 3])).toBe(1); + }); + + it("interpolate averages prev/next with graceful fallbacks", () => { + expect(interpolate(10, 20)).toBe(15); + expect(interpolate(NaN, 20)).toBe(20); + expect(interpolate(10, NaN)).toBe(10); + expect(interpolate(NaN, NaN)).toBe(0); + }); +}); + +describe("SequenceTracker", () => { + it("learns a +1 cadence and reports zero variability", () => { + const t = new SequenceTracker(); + for (let i = 1; i <= 20; i++) t.push(i); + expect(t.medianStep()).toBe(1); + expect(t.mad()).toBe(0); + expect(t.expectedMaxStep()).toBeGreaterThanOrEqual(1); + }); + + it("adapts to a new baseline after reset (within the window)", () => { + const t = new SequenceTracker(); + for (let i = 1; i <= 50; i++) t.push(i); + expect(t.contains(5)).toBe(true); + t.reset(); + expect(t.contains(5)).toBe(false); + expect(t.size).toBe(0); + for (let i = 0; i < 100; i++) t.push(1000 + i); + expect(t.medianStep()).toBe(1); + expect(t.contains(1005)).toBe(true); + }); +}); + +describe("FrameValidator - epoch reset detection (THE bug fix)", () => { + it("accepts the first frame of a new epoch with ANY sequence and no gap math", () => { + const v = new FrameValidator(); + for (let s = 0; s <= 5201; s++) { + v.validate(frame(s, s), [s === 0 ? NaN : s - 1]); + } + expect(v.lastSequenceNumber).toBe(5201); + + v.beginEpoch(); + expect(v.awaitingFirstFrame).toBe(true); + + // New stream resets its counter to 3. This MUST NOT be read as a 5198 drop. + const result = v.validate(frame(3, 999), [NaN]); + expect(result.accepted).toBe(true); + expect(result.reason).toBe("epoch-reset"); + expect(result.gap).toBe(0); + expect(result.fills).toHaveLength(1); + expect(result.fills[0].points).toHaveLength(1); + expect(Number.isNaN(result.fills[0].points[0].value)).toBe(true); + expect(v.lastSequenceNumber).toBe(3); + }); + + it("does not flood the store with thousands of points on reset", () => { + const { validator, store, prevValues } = freshHarness(); + for (let s = 0; s <= 5201; s++) { + ingestFrame(validator, store, prevValues, frame(s, s)); + } + const before = store.datasets[0].length; + + validator.beginEpoch(); + prevValues[0] = NaN; + ingestFrame(validator, store, prevValues, frame(3, 999)); + + const after = store.datasets[0].length; + expect(after - before).toBe(2); + expect(store.syntheticPointCount()).toBe(1); + expect(after).toBeLessThan(before + 10); + const last = store.lastPoint(0)!; + expect(last.value).toBe(999); + expect(last.synthetic).toBe(false); + }); +}); + +describe("FrameValidator - decimated interpolation", () => { + it("fills small gaps with (prev+next)/2 synthetic points", () => { + const { validator, store, prevValues } = freshHarness(); + ingestFrame(validator, store, prevValues, frame(0, 10)); + ingestFrame(validator, store, prevValues, frame(1, 20)); + const result = validator.validate(frame(6, 30), prevValues); + expect(result.accepted).toBe(true); + expect(result.reason).toBe("interpolated"); + expect(result.gap).toBe(4); + expect(result.fills[0].points).toHaveLength(4); + for (const p of result.fills[0].points) { + expect(p.value).toBe(25); + expect(p.synthetic).toBe(true); + } + }); + + it("caps gaps larger than 300 to a single NaN sentinel", () => { + const { validator, store, prevValues } = freshHarness(); + ingestFrame(validator, store, prevValues, frame(0, 10)); + const before = store.datasets[0].length; + + // A single ingest both classifies and mutates the store. + const outcome = ingestFrame(validator, store, prevValues, frame(500, 50)); + const after = store.datasets[0].length; + + expect(outcome.accepted).toBe(true); + expect(outcome.reason).toBe("gap-separator"); + // ONE sentinel + ONE real point only (never 499 synthetic nulls). + expect(after - before).toBe(2); + expect(Number.isNaN(store.datasets[0][before].value)).toBe(true); + expect(store.datasets[0][before].synthetic).toBe(true); + expect(store.datasets[0][after - 1].value).toBe(50); + }); + + it("treats gaps > 1000 as an effective reset (epoch-reset reason)", () => { + const v = new FrameValidator(); + v.validate(frame(0, 1), [NaN]); + const result = v.validate(frame(6000, 2), [1]); + expect(result.accepted).toBe(true); + expect(result.reason).toBe("epoch-reset"); + expect(result.fills[0].points).toHaveLength(1); + expect(Number.isNaN(result.fills[0].points[0].value)).toBe(true); + }); +}); + +describe("FrameValidator - wrap, duplicate, reorder", () => { + it("handles u16 wrap (65535 -> 0) as a nominal next frame", () => { + const v = new FrameValidator(); + expect(v.validate(frame(65534, 1), [NaN]).reason).toBe("epoch-reset"); + expect(v.validate(frame(65535, 2), [1]).reason).toBe("accepted"); + expect(v.validate(frame(0, 3), [2]).reason).toBe("accepted"); + expect(v.validate(frame(1, 4), [3]).reason).toBe("accepted"); + expect(v.lastSequenceNumber).toBe(1); + }); + + it("drops exact duplicate frames", () => { + const v = new FrameValidator(); + v.validate(frame(5, 1), [NaN]); + const dup = v.validate(frame(5, 1), [1]); + expect(dup.accepted).toBe(false); + expect(dup.reason).toBe("duplicate"); + }); + + it("classifies late/reordered frames without moving the high-water mark", () => { + const v = new FrameValidator(); + v.validate(frame(10, 1), [NaN]); + v.validate(frame(11, 2), [1]); + v.validate(frame(12, 3), [2]); + const late = v.validate(frame(11, 99), [3]); + expect(late.accepted).toBe(true); + expect(late.reason).toBe("reordered"); + expect(late.fills).toHaveLength(0); + expect(v.lastSequenceNumber).toBe(12); + expect(v.validate(frame(13, 4), [3]).reason).toBe("accepted"); + }); +}); + +describe("FrameValidator - connection epoch bookkeeping", () => { + it("mints a new monotonic connectionId on every beginEpoch", () => { + const v = new FrameValidator(); + const first = v.connectionEpochId; + v.beginEpoch(); + const second = v.connectionEpochId; + v.beginEpoch(); + const third = v.connectionEpochId; + expect(first).not.toBe(second); + expect(second).not.toBe(third); + expect(v.epoch).toBe(2); + }); + + it("first-ever epoch inserts no boundary sentinel (no prior data)", () => { + const v = new FrameValidator(); + const result = v.validate(frame(42, 1), [NaN]); + expect(result.reason).toBe("epoch-reset"); + expect(result.fills).toHaveLength(0); + }); +});