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:
335
atomizer-dashboard/frontend/src/hooks/useOptimizationStream.ts
Normal file
335
atomizer-dashboard/frontend/src/hooks/useOptimizationStream.ts
Normal 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;
|
||||
Reference in New Issue
Block a user