mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-23 15:42:12 +03:00
* fix(dashboard): send periodic WS heartbeat pings to stop live-dashboard reconnect churn
The live-dashboard WS client (src/hooks/useLiveDashboard.ts) only sent a
subscribe frame on open and never emitted the protocol's { type: "ping" }
heartbeat. The server (src/server/ws/liveServer.ts) refreshes client
liveness only from inbound messages and terminates any client idle past
HEARTBEAT_TIMEOUT_MS (35s), so a healthy, connected-but-idle dashboard
client was force-terminated roughly every 35-45s, causing constant
reconnect churn (#10319).
Fix (both directions, per the analyzed plan):
- Client: start a 15s ping interval on open, cleared on close/unmount/
reconnect, so the connection stays inside the server's liveness window.
- Server (defense in depth): the outbound heartbeat pong now also bumps
client.lastActivity, so even a third-party client that never pings is
not dropped for being idle.
Regression coverage:
- tests/unit/useLiveDashboard-heartbeat.test.tsx: fast fake-timer check
that the hook emits periodic ping frames and cleans up the interval on
close/unmount (no leaked timers).
- tests/integration/live-ws-heartbeat-keepalive.test.ts: real WS-server
integration test asserting a silent-but-subscribed client stays
connected past the 35s heartbeat timeout (~50s window), converted from
the plan file's TDD RED repro.
Closes #10319
* fix(dashboard): stop renewing stale LiveWS sockets
Keep application-level heartbeat responses from refreshing server liveness, and add a regression covering silent stale sockets alongside clients that answer protocol heartbeats.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
587 lines
19 KiB
TypeScript
587 lines
19 KiB
TypeScript
/**
|
|
* useLiveDashboard — React hooks for real-time dashboard WebSocket
|
|
*
|
|
* Provides hooks for connecting to the live dashboard WebSocket server
|
|
* and subscribing to event channels.
|
|
*
|
|
* Usage:
|
|
* const { requests, isConnected } = useLiveRequests();
|
|
* const { comboEvents, lastComboEvent } = useLiveComboStatus();
|
|
*/
|
|
|
|
"use client";
|
|
|
|
import { useEffect, useRef, useState, useCallback } from "react";
|
|
import type { DashboardChannel, DashboardEventName } from "@/lib/events/types";
|
|
import { deriveLiveWsPath } from "@/shared/utils/wsPath";
|
|
|
|
// ── Config ────────────────────────────────────────────────────────────────
|
|
|
|
const WS_RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
|
|
// Must stay <= the server's HEARTBEAT_TIMEOUT_MS (35s in src/server/ws/liveServer.ts) so a
|
|
// healthy, connected-but-idle client is never force-terminated by the server's heartbeat sweep.
|
|
// Matches the server's own HEARTBEAT_INTERVAL_MS (15s).
|
|
const CLIENT_PING_INTERVAL_MS = 15_000;
|
|
|
|
/** Only accept ws:// or wss:// URLs (mirrors the guard in src/app/api/v1/ws/route.ts). */
|
|
function sanitizeWsPublicUrl(url: unknown): string | null {
|
|
if (typeof url !== "string" || url.length === 0) return null;
|
|
return url.startsWith("ws://") || url.startsWith("wss://") ? url : null;
|
|
}
|
|
|
|
// Build-time inlined value (Docker/npm prebuilt images won't have this — the
|
|
// runtime value is discovered via the /api/v1/ws?handshake=1 handshake below).
|
|
const BUILD_TIME_PUBLIC_WS_URL = sanitizeWsPublicUrl(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL);
|
|
const BUILD_TIME_WS_PATH = deriveLiveWsPath(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL);
|
|
|
|
function getDefaultWsUrl(): string {
|
|
if (BUILD_TIME_PUBLIC_WS_URL) return BUILD_TIME_PUBLIC_WS_URL;
|
|
if (typeof window === "undefined") return `ws://localhost:20132${BUILD_TIME_WS_PATH}`;
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const { hostname } = window.location;
|
|
// Bug #1 fix: Use the WS server's actual port (20132) for both loopback
|
|
// and non-loopback clients. Previously the non-loopback branch tried to
|
|
// upgrade the HTTP port (window.location.host) which has no upgrade
|
|
// handler in src/proxy.ts. If the user wants the upgrade to go through
|
|
// Next.js (same-origin), they should explicitly pass `wsUrl`.
|
|
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
|
return `${protocol}//${hostname}:20132${BUILD_TIME_WS_PATH}`;
|
|
}
|
|
return `${protocol}//${hostname}:20132${BUILD_TIME_WS_PATH}`;
|
|
}
|
|
|
|
const DEFAULT_WS_URL = getDefaultWsUrl();
|
|
|
|
// ── Types ─────────────────────────────────────────────────────────────────
|
|
|
|
export interface WsEventPayload {
|
|
event: string;
|
|
channel: DashboardChannel;
|
|
data: unknown;
|
|
timestamp: number;
|
|
}
|
|
|
|
export interface DashboardConnectionState {
|
|
isConnected: boolean;
|
|
isConnecting: boolean;
|
|
error: string | null;
|
|
reconnectAttempt: number;
|
|
}
|
|
|
|
// ── Core Hook ─────────────────────────────────────────────────────────────
|
|
|
|
export interface UseLiveDashboardOptions {
|
|
/** WebSocket URL (default: ws://hostname:20132) */
|
|
wsUrl?: string;
|
|
/** Whether the WebSocket connection should be active (default: true) */
|
|
enabled?: boolean;
|
|
/** API key for authentication */
|
|
apiKey?: string;
|
|
/** Channels to subscribe to */
|
|
channels?: DashboardChannel[];
|
|
/** Auto-reconnect on disconnect (default: true) */
|
|
autoReconnect?: boolean;
|
|
/** Event callback */
|
|
onEvent?: (payload: WsEventPayload) => void;
|
|
}
|
|
|
|
/**
|
|
* Core WebSocket connection hook.
|
|
* Manages connection lifecycle, reconnection, and event streaming.
|
|
*/
|
|
export function useLiveDashboard({
|
|
wsUrl,
|
|
enabled = true,
|
|
apiKey,
|
|
channels = ["requests", "combo", "credentials"],
|
|
autoReconnect = true,
|
|
onEvent,
|
|
}: UseLiveDashboardOptions = {}) {
|
|
const [connection, setConnection] = useState<DashboardConnectionState>({
|
|
isConnected: false,
|
|
isConnecting: false,
|
|
error: null,
|
|
reconnectAttempt: 0,
|
|
});
|
|
|
|
// Runtime discovery of the public WS URL via the handshake endpoint.
|
|
// NEXT_PUBLIC_* env vars are inlined at build time, so prebuilt Docker/npm
|
|
// images never see a runtime NEXT_PUBLIC_LIVE_WS_PUBLIC_URL — the server
|
|
// echoes it in the /api/v1/ws?handshake=1 response instead.
|
|
// Skipped when the caller passes an explicit wsUrl or the env was inlined.
|
|
const needsHandshake = !wsUrl && !BUILD_TIME_PUBLIC_WS_URL && typeof window !== "undefined";
|
|
const [handshakeUrl, setHandshakeUrl] = useState<string | null>(null);
|
|
const [handshakePath, setHandshakePath] = useState<string | null>(null);
|
|
const [wsUrlResolved, setWsUrlResolved] = useState(!needsHandshake);
|
|
|
|
useEffect(() => {
|
|
if (!needsHandshake || wsUrlResolved) return;
|
|
let cancelled = false;
|
|
fetch("/api/v1/ws?handshake=1")
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
.then((body) => {
|
|
if (cancelled) return;
|
|
const publicUrl = sanitizeWsPublicUrl(body?.live?.publicUrl);
|
|
if (publicUrl) setHandshakeUrl(publicUrl);
|
|
if (typeof body?.live?.path === "string" && body.live.path.startsWith("/")) {
|
|
setHandshakePath(body.live.path);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Handshake unavailable — fall back to the default URL.
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setWsUrlResolved(true);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [needsHandshake, wsUrlResolved]);
|
|
|
|
const effectiveWsUrl = (() => {
|
|
if (wsUrl) return wsUrl;
|
|
if (handshakeUrl) return handshakeUrl;
|
|
if (handshakePath && handshakePath !== BUILD_TIME_WS_PATH) {
|
|
try {
|
|
const url = new URL(DEFAULT_WS_URL);
|
|
url.pathname = handshakePath;
|
|
return url.toString();
|
|
} catch {
|
|
return DEFAULT_WS_URL;
|
|
}
|
|
}
|
|
return DEFAULT_WS_URL;
|
|
})();
|
|
|
|
const [events, setEvents] = useState<WsEventPayload[]>([]);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const mountedRef = useRef(true);
|
|
|
|
const stopPingHeartbeat = useCallback(() => {
|
|
if (pingIntervalRef.current) {
|
|
clearInterval(pingIntervalRef.current);
|
|
pingIntervalRef.current = null;
|
|
}
|
|
}, []);
|
|
const maxEvents = 500;
|
|
|
|
const onEventRef = useRef(onEvent);
|
|
useEffect(() => {
|
|
onEventRef.current = onEvent;
|
|
}, [onEvent]);
|
|
|
|
const connect = useCallback(() => {
|
|
if (!mountedRef.current) return;
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
|
|
|
setConnection((prev) => ({
|
|
...prev,
|
|
isConnecting: true,
|
|
error: null,
|
|
}));
|
|
|
|
try {
|
|
const wsUrlWithAuth = apiKey
|
|
? `${effectiveWsUrl}?token=${encodeURIComponent(apiKey)}`
|
|
: effectiveWsUrl;
|
|
|
|
const ws = new WebSocket(wsUrlWithAuth);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
if (!mountedRef.current) return;
|
|
setConnection({
|
|
isConnected: true,
|
|
isConnecting: false,
|
|
error: null,
|
|
reconnectAttempt: 0,
|
|
});
|
|
|
|
// Subscribe to channels
|
|
ws.send(JSON.stringify({ type: "subscribe", channels }));
|
|
|
|
// Heartbeat: send a periodic ping so the server (which only refreshes
|
|
// liveness from inbound messages) never terminates a healthy, idle
|
|
// connection for exceeding its inactivity timeout (#10319).
|
|
stopPingHeartbeat();
|
|
pingIntervalRef.current = setInterval(() => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: "ping" }));
|
|
}
|
|
}, CLIENT_PING_INTERVAL_MS);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
if (!mountedRef.current) return;
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
|
|
if (msg.type === "event") {
|
|
const payload: WsEventPayload = {
|
|
event: msg.event,
|
|
channel: msg.channel,
|
|
data: msg.data,
|
|
timestamp: msg.timestamp || Date.now(),
|
|
};
|
|
setEvents((prev) => {
|
|
const next = [...prev, payload];
|
|
return next.length > maxEvents ? next.slice(-maxEvents) : next;
|
|
});
|
|
onEventRef.current?.(payload);
|
|
} else if (msg.type === "pong") {
|
|
// Heartbeat response
|
|
} else if (msg.type === "welcome") {
|
|
// Send backlog
|
|
if (Array.isArray(msg.data)) {
|
|
setEvents((prev) => {
|
|
const next = [...prev, ...msg.data];
|
|
return next.length > maxEvents ? next.slice(-maxEvents) : next;
|
|
});
|
|
for (const item of msg.data) {
|
|
const payload: WsEventPayload = {
|
|
event: item.event,
|
|
channel: item.channel,
|
|
data: item.data,
|
|
timestamp: item.timestamp || Date.now(),
|
|
};
|
|
onEventRef.current?.(payload);
|
|
}
|
|
}
|
|
} else if (msg.type === "error") {
|
|
console.error("[LiveWS] Server error:", msg.code, msg.message);
|
|
}
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
stopPingHeartbeat();
|
|
if (!mountedRef.current) return;
|
|
wsRef.current = null;
|
|
setConnection((prev) => ({
|
|
...prev,
|
|
isConnected: false,
|
|
isConnecting: false,
|
|
}));
|
|
|
|
if (autoReconnect) {
|
|
const attempt = connection.reconnectAttempt;
|
|
const delay = WS_RECONNECT_DELAYS[Math.min(attempt, WS_RECONNECT_DELAYS.length - 1)];
|
|
reconnectTimeoutRef.current = setTimeout(() => {
|
|
setConnection((prev) => ({
|
|
...prev,
|
|
reconnectAttempt: prev.reconnectAttempt + 1,
|
|
}));
|
|
}, delay);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
if (!mountedRef.current) return;
|
|
setConnection((prev) => ({
|
|
...prev,
|
|
isConnecting: false,
|
|
error: "Connection failed",
|
|
}));
|
|
};
|
|
} catch (err) {
|
|
setConnection((prev) => ({
|
|
...prev,
|
|
isConnecting: false,
|
|
error: err instanceof Error ? err.message : "Connection failed",
|
|
}));
|
|
}
|
|
}, [
|
|
effectiveWsUrl,
|
|
apiKey,
|
|
channels.join(","),
|
|
autoReconnect,
|
|
connection.reconnectAttempt,
|
|
stopPingHeartbeat,
|
|
]);
|
|
|
|
// Connect on mount and on reconnect trigger
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
if (!enabled) {
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
reconnectTimeoutRef.current = null;
|
|
}
|
|
stopPingHeartbeat();
|
|
wsRef.current?.close();
|
|
wsRef.current = null;
|
|
setConnection({
|
|
isConnected: false,
|
|
isConnecting: false,
|
|
error: null,
|
|
reconnectAttempt: 0,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Wait for the handshake URL resolution before opening the socket, so we
|
|
// never connect to the hardcoded default and then flap to the public URL.
|
|
if (!wsUrlResolved) return;
|
|
|
|
connect();
|
|
return () => {
|
|
mountedRef.current = false;
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
}
|
|
stopPingHeartbeat();
|
|
wsRef.current?.close();
|
|
};
|
|
}, [connect, enabled, wsUrlResolved, stopPingHeartbeat]);
|
|
|
|
// Connect (for manual retry)
|
|
const reconnect = useCallback(() => {
|
|
wsRef.current?.close();
|
|
setConnection((prev) => ({
|
|
...prev,
|
|
reconnectAttempt: prev.reconnectAttempt + 1,
|
|
}));
|
|
}, []);
|
|
|
|
return {
|
|
connection,
|
|
events,
|
|
reconnect,
|
|
/** Filter events by channel */
|
|
getEventsByChannel: useCallback(
|
|
(channel: DashboardChannel) => events.filter((e) => e.channel === channel),
|
|
[events]
|
|
),
|
|
/** Filter events by name */
|
|
getEventsByName: useCallback(
|
|
(eventName: string) => events.filter((e) => e.event === eventName),
|
|
[events]
|
|
),
|
|
/** Clear event history */
|
|
clearEvents: useCallback(() => setEvents([]), []),
|
|
};
|
|
}
|
|
|
|
// ── Request Monitoring Hook ───────────────────────────────────────────────
|
|
|
|
export interface LiveRequest {
|
|
id: string;
|
|
model: string;
|
|
provider: string;
|
|
timestamp: number;
|
|
status: "pending" | "running" | "success" | "error";
|
|
tokensInput?: number;
|
|
tokensOutput?: number;
|
|
latencyMs?: number;
|
|
error?: string;
|
|
comboName?: string;
|
|
}
|
|
|
|
/**
|
|
* Hook for monitoring live requests.
|
|
*/
|
|
export function useLiveRequests(options?: UseLiveDashboardOptions) {
|
|
const [requestState, setRequestState] = useState<{
|
|
active: Map<string, LiveRequest>;
|
|
completed: LiveRequest[];
|
|
}>({
|
|
active: new Map(),
|
|
completed: [],
|
|
});
|
|
const maxCompleted = 100;
|
|
|
|
const handleEvent = useCallback((event: WsEventPayload) => {
|
|
if (event.channel !== "requests") return;
|
|
|
|
if (event.event === "request.started") {
|
|
const data = event.data as any;
|
|
setRequestState((prev) => {
|
|
const active = new Map(prev.active);
|
|
active.set(data.id, {
|
|
id: data.id,
|
|
model: data.model,
|
|
provider: data.provider,
|
|
timestamp: data.timestamp,
|
|
status: "pending",
|
|
comboName: data.comboName,
|
|
});
|
|
return { active, completed: prev.completed };
|
|
});
|
|
} else if (event.event === "request.streaming") {
|
|
const data = event.data as any;
|
|
setRequestState((prev) => {
|
|
const active = new Map(prev.active);
|
|
const existing = active.get(data.id);
|
|
if (existing) {
|
|
active.set(data.id, { ...existing, status: "running" });
|
|
}
|
|
return { active, completed: prev.completed };
|
|
});
|
|
} else if (event.event === "request.completed") {
|
|
const data = event.data as any;
|
|
setRequestState((prev) => {
|
|
const active = new Map(prev.active);
|
|
const existing = active.get(data.id);
|
|
if (existing) {
|
|
active.delete(data.id);
|
|
const done: LiveRequest = {
|
|
...existing,
|
|
status: data.status === "success" ? "success" : "error",
|
|
tokensInput: data.tokensInput,
|
|
tokensOutput: data.tokensOutput,
|
|
latencyMs: data.latencyMs,
|
|
error: data.error,
|
|
};
|
|
const completed = [done, ...prev.completed].slice(0, maxCompleted);
|
|
return { active, completed };
|
|
}
|
|
return prev;
|
|
});
|
|
} else if (event.event === "request.failed") {
|
|
const data = event.data as any;
|
|
setRequestState((prev) => {
|
|
const active = new Map(prev.active);
|
|
const existing = active.get(data.id);
|
|
if (existing) {
|
|
active.delete(data.id);
|
|
const done: LiveRequest = {
|
|
...existing,
|
|
status: "error",
|
|
error: data.error,
|
|
latencyMs: data.latencyMs,
|
|
};
|
|
const completed = [done, ...prev.completed].slice(0, maxCompleted);
|
|
return { active, completed };
|
|
}
|
|
return prev;
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
const { connection, reconnect } = useLiveDashboard({
|
|
channels: ["requests"],
|
|
onEvent: handleEvent,
|
|
...options,
|
|
});
|
|
|
|
return {
|
|
activeRequests: Array.from(requestState.active.values()),
|
|
completedRequests: requestState.completed,
|
|
activeCount: requestState.active.size,
|
|
isConnected: connection.isConnected,
|
|
reconnect,
|
|
};
|
|
}
|
|
|
|
// ── Combo Status Hook ─────────────────────────────────────────────────────
|
|
|
|
export interface LiveComboEvent {
|
|
comboName: string;
|
|
targetIndex: number;
|
|
provider: string;
|
|
model: string;
|
|
type: "attempt" | "succeeded" | "failed";
|
|
/** Routing strategy, carried on the attempt payload (used by the Combo Studio). */
|
|
strategy?: string;
|
|
latencyMs?: number;
|
|
error?: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
/**
|
|
* Hook for monitoring live combo cascade status.
|
|
*/
|
|
export function useLiveComboStatus(options?: UseLiveDashboardOptions) {
|
|
const [comboEvents, setComboEvents] = useState<LiveComboEvent[]>([]);
|
|
const maxComboEvents = 200;
|
|
|
|
const handleEvent = useCallback((event: WsEventPayload) => {
|
|
if (event.channel !== "combo") return;
|
|
|
|
const data = event.data as any;
|
|
let comboEvent: LiveComboEvent | null = null;
|
|
|
|
if (event.event === "combo.target.attempt") {
|
|
comboEvent = {
|
|
comboName: data.comboName,
|
|
targetIndex: data.targetIndex,
|
|
provider: data.provider,
|
|
model: data.model,
|
|
type: "attempt",
|
|
strategy: data.strategy,
|
|
timestamp: event.timestamp,
|
|
};
|
|
} else if (event.event === "combo.target.succeeded") {
|
|
comboEvent = {
|
|
comboName: data.comboName,
|
|
targetIndex: data.targetIndex,
|
|
provider: data.provider,
|
|
model: data.model,
|
|
type: "succeeded",
|
|
latencyMs: data.latencyMs,
|
|
timestamp: event.timestamp,
|
|
};
|
|
} else if (event.event === "combo.target.failed") {
|
|
comboEvent = {
|
|
comboName: data.comboName,
|
|
targetIndex: data.targetIndex,
|
|
provider: data.provider,
|
|
model: data.model,
|
|
type: "failed",
|
|
error: data.error,
|
|
latencyMs: data.latencyMs,
|
|
timestamp: event.timestamp,
|
|
};
|
|
}
|
|
|
|
if (comboEvent) {
|
|
setComboEvents((prev) => [comboEvent!, ...prev].slice(0, maxComboEvents));
|
|
}
|
|
}, []);
|
|
|
|
const { connection, reconnect } = useLiveDashboard({
|
|
channels: ["combo"],
|
|
onEvent: handleEvent,
|
|
...options,
|
|
});
|
|
|
|
/** Get events for a specific combo */
|
|
const getComboHistory = useCallback(
|
|
(comboName: string) => comboEvents.filter((e) => e.comboName === comboName),
|
|
[comboEvents]
|
|
);
|
|
|
|
/** Get the last event for a specific combo */
|
|
const getLastComboEvent = useCallback(
|
|
(comboName: string) => comboEvents.find((e) => e.comboName === comboName),
|
|
[comboEvents]
|
|
);
|
|
|
|
return {
|
|
comboEvents,
|
|
activeCombos: new Set(comboEvents.map((e) => e.comboName)),
|
|
isConnected: connection.isConnected,
|
|
getComboHistory,
|
|
getLastComboEvent,
|
|
reconnect,
|
|
};
|
|
}
|
|
|
|
// ── Connection Status Hook ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Hook for checking connection status only (no events).
|
|
*/
|
|
export function useLiveConnectionStatus(options?: UseLiveDashboardOptions) {
|
|
const { connection, reconnect } = useLiveDashboard({
|
|
channels: [],
|
|
...options,
|
|
});
|
|
return { ...connection, reconnect };
|
|
}
|