feat: Add WebSocket live updates and convergence visualization

Phase 4 - Live Updates:
- Create useOptimizationStream hook for real-time trial updates
- Replace polling with WebSocket subscription in SpecRenderer
- Auto-report errors to ErrorPanel via panel store
- Add progress tracking (FEA count, NN count, best trial)

Phase 5 - Convergence Visualization:
- Add ConvergenceSparkline component for mini line charts
- Add ProgressRing component for circular progress indicator
- Update ObjectiveNode to show convergence trend sparkline
- Add history field to ObjectiveNodeData schema
- Add live progress indicator centered on canvas when running

Bug fixes:
- Fix TypeScript errors in FloatingIntrospectionPanel (type casts)
- Fix ValidationPanel using wrong store method (selectNode vs setSelectedNodeId)
- Fix NodeConfigPanelV2 unused state variable
- Fix specValidator source.extractor_id path
- Clean up unused imports across components
This commit is contained in:
2026-01-21 21:48:35 -05:00
parent c224b16ac3
commit 2cb8dccc3a
10 changed files with 764 additions and 167 deletions

View File

@@ -0,0 +1,335 @@
/**
* useOptimizationStream - Enhanced WebSocket hook for real-time optimization updates
*
* This hook provides:
* - Real-time trial updates (no polling needed)
* - Best trial tracking
* - Progress tracking
* - Error detection and reporting
* - Integration with panel store for error display
* - Automatic reconnection
*
* Usage:
* ```tsx
* const {
* isConnected,
* progress,
* bestTrial,
* recentTrials,
* status
* } = useOptimizationStream(studyId);
* ```
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import useWebSocket, { ReadyState } from 'react-use-websocket';
import { usePanelStore } from './usePanelStore';
// ============================================================================
// Types
// ============================================================================
export interface TrialData {
trial_number: number;
trial_num: number;
objective: number | null;
values: number[];
params: Record<string, number>;
user_attrs: Record<string, unknown>;
source: 'FEA' | 'NN' | string;
start_time: string;
end_time: string;
study_name: string;
constraint_satisfied: boolean;
}
export interface ProgressData {
current: number;
total: number;
percentage: number;
fea_count: number;
nn_count: number;
timestamp: string;
}
export interface BestTrialData {
trial_number: number;
value: number;
params: Record<string, number>;
improvement: number;
}
export interface ParetoData {
pareto_front: Array<{
trial_number: number;
values: number[];
params: Record<string, number>;
constraint_satisfied: boolean;
source: string;
}>;
count: number;
}
export type OptimizationStatus = 'disconnected' | 'connecting' | 'connected' | 'running' | 'paused' | 'completed' | 'failed';
export interface OptimizationStreamState {
isConnected: boolean;
status: OptimizationStatus;
progress: ProgressData | null;
bestTrial: BestTrialData | null;
recentTrials: TrialData[];
paretoFront: ParetoData | null;
lastUpdate: number | null;
error: string | null;
}
// ============================================================================
// Hook
// ============================================================================
interface UseOptimizationStreamOptions {
/** Maximum number of recent trials to keep */
maxRecentTrials?: number;
/** Callback when a new trial completes */
onTrialComplete?: (trial: TrialData) => void;
/** Callback when a new best is found */
onNewBest?: (best: BestTrialData) => void;
/** Callback on progress update */
onProgress?: (progress: ProgressData) => void;
/** Whether to auto-report errors to the error panel */
autoReportErrors?: boolean;
}
export function useOptimizationStream(
studyId: string | null | undefined,
options: UseOptimizationStreamOptions = {}
) {
const {
maxRecentTrials = 20,
onTrialComplete,
onNewBest,
onProgress,
autoReportErrors = true,
} = options;
// Panel store for error reporting
const { addError } = usePanelStore();
// State
const [state, setState] = useState<OptimizationStreamState>({
isConnected: false,
status: 'disconnected',
progress: null,
bestTrial: null,
recentTrials: [],
paretoFront: null,
lastUpdate: null,
error: null,
});
// Track last error timestamp to avoid duplicates
const lastErrorTime = useRef<number>(0);
// Build WebSocket URL
const socketUrl = studyId
? `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${
import.meta.env.DEV ? 'localhost:8001' : window.location.host
}/api/ws/optimization/${encodeURIComponent(studyId)}`
: null;
// WebSocket connection
const { sendMessage, lastMessage, readyState } = useWebSocket(socketUrl, {
shouldReconnect: () => true,
reconnectAttempts: 10,
reconnectInterval: 3000,
onOpen: () => {
console.log('[OptStream] Connected to optimization stream');
setState(prev => ({ ...prev, isConnected: true, status: 'connected', error: null }));
},
onClose: () => {
console.log('[OptStream] Disconnected from optimization stream');
setState(prev => ({ ...prev, isConnected: false, status: 'disconnected' }));
},
onError: (event) => {
console.error('[OptStream] WebSocket error:', event);
setState(prev => ({ ...prev, error: 'WebSocket connection error' }));
},
});
// Update connection status
useEffect(() => {
const statusMap: Record<ReadyState, OptimizationStatus> = {
[ReadyState.CONNECTING]: 'connecting',
[ReadyState.OPEN]: 'connected',
[ReadyState.CLOSING]: 'disconnected',
[ReadyState.CLOSED]: 'disconnected',
[ReadyState.UNINSTANTIATED]: 'disconnected',
};
setState(prev => ({
...prev,
isConnected: readyState === ReadyState.OPEN,
status: prev.status === 'running' || prev.status === 'completed' || prev.status === 'failed'
? prev.status
: statusMap[readyState] || 'disconnected',
}));
}, [readyState]);
// Process incoming messages
useEffect(() => {
if (!lastMessage?.data) return;
try {
const message = JSON.parse(lastMessage.data);
const { type, data } = message;
switch (type) {
case 'connected':
console.log('[OptStream] Connection confirmed:', data.message);
break;
case 'trial_completed':
handleTrialComplete(data as TrialData);
break;
case 'new_best':
handleNewBest(data as BestTrialData);
break;
case 'progress':
handleProgress(data as ProgressData);
break;
case 'pareto_update':
handleParetoUpdate(data as ParetoData);
break;
case 'heartbeat':
case 'pong':
// Keep-alive messages
break;
case 'error':
handleError(data);
break;
default:
console.log('[OptStream] Unknown message type:', type, data);
}
} catch (e) {
console.error('[OptStream] Failed to parse message:', e);
}
}, [lastMessage]);
// Handler functions
const handleTrialComplete = useCallback((trial: TrialData) => {
setState(prev => {
const newTrials = [trial, ...prev.recentTrials].slice(0, maxRecentTrials);
return {
...prev,
recentTrials: newTrials,
lastUpdate: Date.now(),
status: 'running',
};
});
onTrialComplete?.(trial);
}, [maxRecentTrials, onTrialComplete]);
const handleNewBest = useCallback((best: BestTrialData) => {
setState(prev => ({
...prev,
bestTrial: best,
lastUpdate: Date.now(),
}));
onNewBest?.(best);
}, [onNewBest]);
const handleProgress = useCallback((progress: ProgressData) => {
setState(prev => {
// Determine status based on progress
let status: OptimizationStatus = prev.status;
if (progress.current > 0 && progress.current < progress.total) {
status = 'running';
} else if (progress.current >= progress.total) {
status = 'completed';
}
return {
...prev,
progress,
status,
lastUpdate: Date.now(),
};
});
onProgress?.(progress);
}, [onProgress]);
const handleParetoUpdate = useCallback((pareto: ParetoData) => {
setState(prev => ({
...prev,
paretoFront: pareto,
lastUpdate: Date.now(),
}));
}, []);
const handleError = useCallback((errorData: { message: string; details?: string; trial?: number }) => {
const now = Date.now();
// Avoid duplicate errors within 5 seconds
if (now - lastErrorTime.current < 5000) return;
lastErrorTime.current = now;
setState(prev => ({
...prev,
error: errorData.message,
status: 'failed',
}));
if (autoReportErrors) {
addError({
type: 'system_error',
message: errorData.message,
details: errorData.details,
trial: errorData.trial,
recoverable: true,
suggestions: ['Check the optimization logs', 'Try restarting the optimization'],
timestamp: now,
});
}
}, [autoReportErrors, addError]);
// Send ping to keep connection alive
useEffect(() => {
if (readyState !== ReadyState.OPEN) return;
const interval = setInterval(() => {
sendMessage(JSON.stringify({ type: 'ping' }));
}, 25000); // Ping every 25 seconds
return () => clearInterval(interval);
}, [readyState, sendMessage]);
// Reset state when study changes
useEffect(() => {
setState({
isConnected: false,
status: 'disconnected',
progress: null,
bestTrial: null,
recentTrials: [],
paretoFront: null,
lastUpdate: null,
error: null,
});
}, [studyId]);
return {
...state,
sendPing: () => sendMessage(JSON.stringify({ type: 'ping' })),
};
}
export default useOptimizationStream;