feat: migrar dashboard a React 19 + TypeScript + Vite + Tailwind v4

- Módulo HITL (/cases): 6 patrones de formularios dinámicos para 53 tipos de caso con validación Zod
- Módulo Monitor (/monitor): streaming token-a-token en tiempo real, auto-scroll y notas internas vía WebSocket
- Arquitectura híbrida: REST (canal autoritativo) + WebSocket (difusión/streaming)
- MSW para desarrollo sin backend, hooks de notificaciones/sonido/título preservados
- Backend legacy movido a legacy/, archivos residuales eliminados de raíz
This commit is contained in:
2026-07-23 18:27:03 -05:00
parent 69cc215954
commit 8f044567c0
78 changed files with 11818 additions and 1563 deletions
+199
View File
@@ -0,0 +1,199 @@
import type { WSEnvelope } from '@/types/wsProtocol';
// ─────────────────────────────────────────────────────────────
// Configuration
// ─────────────────────────────────────────────────────────────
const DEFAULT_WS_URL = 'ws://localhost:3000/ws/dashboard';
const WS_URL = import.meta.env.VITE_WS_URL || DEFAULT_WS_URL;
const INITIAL_BACKOFF_MS = 1_000;
const MAX_BACKOFF_MS = 30_000;
const BACKOFF_FACTOR = 2;
// ─────────────────────────────────────────────────────────────
// Connection status
// ─────────────────────────────────────────────────────────────
export type WsConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';
// ─────────────────────────────────────────────────────────────
// Event callback types
// ─────────────────────────────────────────────────────────────
export type MessageCallback = (envelope: WSEnvelope) => void;
export type StatusChangeCallback = (status: WsConnectionStatus) => void;
// ─────────────────────────────────────────────────────────────
// WebSocket Client
// ─────────────────────────────────────────────────────────────
class WsClient {
private ws: WebSocket | null = null;
private status: WsConnectionStatus = 'disconnected';
private onMessageCallback: MessageCallback | null = null;
private onStatusChangeCallback: StatusChangeCallback | null = null;
private reconnectAttempts = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private destroyFlag = false;
// ── Connection ───────────────────────────────────────────
/**
* Initiate (or re-initiate) the WebSocket connection.
* If already connected, it will close and reconnect.
*/
connect(): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
return; // already connected
}
this.destroyFlag = false;
try {
this.ws = new WebSocket(WS_URL);
} catch (err) {
this.setStatus('disconnected');
this.scheduleReconnect();
return;
}
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.setStatus('connected');
};
this.ws.onmessage = (event: MessageEvent) => {
if (!this.onMessageCallback) return;
try {
const envelope: WSEnvelope = JSON.parse(event.data as string);
this.onMessageCallback(envelope);
} catch {
// Malformed message — silently ignore
}
};
this.ws.onclose = () => {
// Only transition to reconnecting if we didn't intentionally close
if (!this.destroyFlag) {
this.setStatus('reconnecting');
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
// onerror will be followed by onclose, so we let onclose handle it
};
}
/**
* Gracefully close the WebSocket connection.
*/
disconnect(): void {
this.destroyFlag = true;
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.onclose = null; // prevent reconnect trigger
this.ws.close();
this.ws = null;
}
this.setStatus('disconnected');
}
// ── Send ─────────────────────────────────────────────────
/**
* Send a typed event through the WebSocket connection.
* Automatically wraps the payload in the standard WSEnvelope.
*/
send(type: string, payload: Record<string, unknown>): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.warn(
'[WS] Cannot send — socket is not open. Status:',
this.status,
);
return;
}
const envelope: WSEnvelope = {
type,
eventId: crypto.randomUUID(),
occurredAt: new Date().toISOString(),
payload,
};
this.ws.send(JSON.stringify(envelope));
}
// ── Callbacks ─────────────────────────────────────────────
/**
* Register a callback for incoming messages.
*/
set onMessage(cb: MessageCallback | null) {
this.onMessageCallback = cb;
}
get onMessage(): MessageCallback | null {
return this.onMessageCallback;
}
// ── Status ────────────────────────────────────────────────
/**
* Get the current connection status.
*/
getStatus(): WsConnectionStatus {
return this.status;
}
/**
* Register a callback for connection status changes.
*/
set onStatusChange(cb: StatusChangeCallback | null) {
this.onStatusChangeCallback = cb;
}
get onStatusChange(): StatusChangeCallback | null {
return this.onStatusChangeCallback;
}
// ── Private helpers ───────────────────────────────────────
private setStatus(status: WsConnectionStatus): void {
this.status = status;
this.onStatusChangeCallback?.(status);
}
private scheduleReconnect(): void {
if (this.destroyFlag) return;
const delay = Math.min(
INITIAL_BACKOFF_MS * Math.pow(BACKOFF_FACTOR, this.reconnectAttempts),
MAX_BACKOFF_MS,
);
this.reconnectAttempts += 1;
this.reconnectTimer = setTimeout(() => {
if (!this.destroyFlag) {
this.setStatus('reconnecting');
this.connect();
}
}, delay);
}
}
// ─────────────────────────────────────────────────────────────
// Singleton export
// ─────────────────────────────────────────────────────────────
export const wsClient = new WsClient();