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

@@ -39,7 +39,9 @@ import {
} from '../../hooks/useSpecStore';
import { useSpecWebSocket } from '../../hooks/useSpecWebSocket';
import { usePanelStore } from '../../hooks/usePanelStore';
import { useOptimizationStream } from '../../hooks/useOptimizationStream';
import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
import { ProgressRing } from './visualization/ConvergenceSparkline';
import { CanvasNodeData } from '../../lib/canvas/schema';
import { validateSpec, canRunOptimization } from '../../lib/validation/specValidator';
@@ -207,107 +209,66 @@ function SpecRendererInner({
// Panel store for validation and error panels
const { setValidationData, addError, openPanel } = usePanelStore();
// Optimization WebSocket stream for real-time updates
const {
status: optimizationStatus,
progress: wsProgress,
bestTrial: wsBestTrial,
recentTrials,
} = useOptimizationStream(studyId, {
autoReportErrors: true,
onTrialComplete: (trial) => {
console.log('[SpecRenderer] Trial completed:', trial.trial_number);
},
onNewBest: (best) => {
console.log('[SpecRenderer] New best found:', best.value);
setShowResults(true); // Auto-show results when new best found
},
});
// Optimization execution state
const [isRunning, setIsRunning] = useState(false);
const isRunning = optimizationStatus === 'running';
const [isStarting, setIsStarting] = useState(false);
const [bestTrial, setBestTrial] = useState<any>(null);
const [showResults, setShowResults] = useState(false);
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked');
// Track last seen error timestamp to avoid duplicates
const lastErrorTimestamp = useRef<number>(0);
// Poll optimization status
useEffect(() => {
if (!studyId) return;
const checkStatus = async () => {
try {
const res = await fetch(`/api/optimization/studies/${studyId}/status`);
if (res.ok) {
const data = await res.json();
setIsRunning(data.status === 'running');
if (data.best_trial) {
setBestTrial(data.best_trial);
// Auto-show results if running or completed and not explicitly disabled
if ((data.status === 'running' || data.status === 'completed') && !showResults) {
setShowResults(true);
}
}
// Handle errors from the optimization process
if (data.error && data.error_timestamp && data.error_timestamp > lastErrorTimestamp.current) {
lastErrorTimestamp.current = data.error_timestamp;
// Classify the error based on the message
let errorType: 'nx_crash' | 'solver_fail' | 'extractor_error' | 'config_error' | 'system_error' | 'unknown' = 'unknown';
const errorMsg = data.error.toLowerCase();
if (errorMsg.includes('nx') || errorMsg.includes('siemens') || errorMsg.includes('journal')) {
errorType = 'nx_crash';
} else if (errorMsg.includes('solver') || errorMsg.includes('nastran') || errorMsg.includes('convergence')) {
errorType = 'solver_fail';
} else if (errorMsg.includes('extractor') || errorMsg.includes('extract') || errorMsg.includes('op2')) {
errorType = 'extractor_error';
} else if (errorMsg.includes('config') || errorMsg.includes('spec') || errorMsg.includes('parameter')) {
errorType = 'config_error';
} else if (errorMsg.includes('system') || errorMsg.includes('permission') || errorMsg.includes('disk')) {
errorType = 'system_error';
}
// Generate suggestions based on error type
const suggestions: string[] = [];
switch (errorType) {
case 'nx_crash':
suggestions.push('Check if NX is running and licensed');
suggestions.push('Verify the model file is not corrupted');
suggestions.push('Try closing and reopening NX');
break;
case 'solver_fail':
suggestions.push('Check the mesh quality in the FEM file');
suggestions.push('Verify boundary conditions are properly defined');
suggestions.push('Review the solver settings');
break;
case 'extractor_error':
suggestions.push('Verify the OP2 file was created successfully');
suggestions.push('Check if the extractor type matches the analysis');
suggestions.push('For custom extractors, review the code for errors');
break;
case 'config_error':
suggestions.push('Run validation to check the spec');
suggestions.push('Verify all design variables have valid bounds');
break;
default:
suggestions.push('Check the optimization logs for more details');
}
addError({
type: errorType,
trial: data.current_trial,
message: data.error,
details: data.error_details,
recoverable: errorType !== 'config_error',
suggestions,
timestamp: data.error_timestamp || Date.now(),
});
}
// Handle failed status
if (data.status === 'failed' && data.error) {
setIsRunning(false);
}
}
} catch (e) {
// Silent fail on polling - network issues shouldn't spam errors
console.debug('Status poll failed:', e);
// Build trial history for sparklines (extract objective values from recent trials)
const trialHistory = useMemo(() => {
const history: Record<string, number[]> = {};
for (const trial of recentTrials) {
// Map objective values - assumes single objective for now
if (trial.objective !== null) {
const key = 'primary';
if (!history[key]) history[key] = [];
history[key].push(trial.objective);
}
// Could also extract individual params/results for multi-objective
}
// Reverse so oldest is first (for sparkline)
for (const key of Object.keys(history)) {
history[key].reverse();
}
return history;
}, [recentTrials]);
// Build best trial data for node display
const bestTrial = useMemo((): {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
results: Record<string, number>;
} | null => {
if (!wsBestTrial) return null;
return {
trial_number: wsBestTrial.trial_number,
objective: wsBestTrial.value,
design_variables: wsBestTrial.params,
results: { primary: wsBestTrial.value, ...wsBestTrial.params },
};
checkStatus();
const interval = setInterval(checkStatus, 3000);
return () => clearInterval(interval);
}, [studyId, addError]);
}, [wsBestTrial]);
// Note: Polling removed - now using WebSocket via useOptimizationStream hook
// The hook handles: status updates, best trial updates, error reporting
// Validate the spec and show results in panel
const handleValidate = useCallback(() => {
@@ -359,7 +320,7 @@ function SpecRendererInner({
const err = await res.json();
throw new Error(err.detail || 'Failed to start');
}
setIsRunning(true);
// isRunning is now derived from WebSocket state (optimizationStatus === 'running')
setValidationStatus('unchecked'); // Clear validation status when running
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to start optimization';
@@ -386,7 +347,7 @@ function SpecRendererInner({
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to stop');
}
setIsRunning(false);
// isRunning will update via WebSocket when optimization actually stops
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to stop optimization';
setError(errorMessage);
@@ -415,47 +376,56 @@ function SpecRendererInner({
const nodes = useMemo(() => {
const baseNodes = specToNodes(spec);
if (showResults && bestTrial) {
return baseNodes.map(node => {
const newData = { ...node.data };
// Map results to nodes
if (node.type === 'designVar') {
// Always map nodes to include history for sparklines (even if not showing results)
return baseNodes.map(node => {
// Create a mutable copy with explicit any type for dynamic property assignment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newData: any = { ...node.data };
// Add history for sparklines on objective nodes
if (node.type === 'objective') {
newData.history = trialHistory['primary'] || [];
}
// Map results to nodes when showing results
if (showResults && bestTrial) {
if (node.type === 'designVar' && newData.expressionName) {
const val = bestTrial.design_variables?.[newData.expressionName];
if (val !== undefined) newData.resultValue = val;
} else if (node.type === 'objective') {
const outputName = newData.outputName;
if (outputName && bestTrial.results && bestTrial.results[outputName] !== undefined) {
newData.resultValue = bestTrial.results[outputName];
}
const outputName = newData.outputName;
if (outputName && bestTrial.results?.[outputName] !== undefined) {
newData.resultValue = bestTrial.results[outputName];
}
} else if (node.type === 'constraint') {
const outputName = newData.outputName;
if (outputName && bestTrial.results && bestTrial.results[outputName] !== undefined) {
const val = bestTrial.results[outputName];
newData.resultValue = val;
// Check feasibility
if (newData.operator === '<=' && newData.value !== undefined) newData.isFeasible = val <= newData.value;
else if (newData.operator === '>=' && newData.value !== undefined) newData.isFeasible = val >= newData.value;
else if (newData.operator === '<' && newData.value !== undefined) newData.isFeasible = val < newData.value;
else if (newData.operator === '>' && newData.value !== undefined) newData.isFeasible = val > newData.value;
else if (newData.operator === '==' && newData.value !== undefined) newData.isFeasible = Math.abs(val - newData.value) < 1e-6;
}
const outputName = newData.outputName;
if (outputName && bestTrial.results?.[outputName] !== undefined) {
const val = bestTrial.results[outputName];
newData.resultValue = val;
// Check feasibility
const op = newData.operator;
const threshold = newData.value;
if (op === '<=' && threshold !== undefined) newData.isFeasible = val <= threshold;
else if (op === '>=' && threshold !== undefined) newData.isFeasible = val >= threshold;
else if (op === '<' && threshold !== undefined) newData.isFeasible = val < threshold;
else if (op === '>' && threshold !== undefined) newData.isFeasible = val > threshold;
else if (op === '==' && threshold !== undefined) newData.isFeasible = Math.abs(val - threshold) < 1e-6;
}
} else if (node.type === 'extractor') {
if (newData.outputNames && newData.outputNames.length > 0 && bestTrial.results) {
const firstOut = newData.outputNames[0];
if (bestTrial.results[firstOut] !== undefined) {
newData.resultValue = bestTrial.results[firstOut];
}
}
const outputNames = newData.outputNames;
if (outputNames && outputNames.length > 0 && bestTrial.results) {
const firstOut = outputNames[0];
if (bestTrial.results[firstOut] !== undefined) {
newData.resultValue = bestTrial.results[firstOut];
}
}
}
return { ...node, data: newData };
});
}
return baseNodes;
}, [spec, showResults, bestTrial]);
}
return { ...node, data: newData };
});
}, [spec, showResults, bestTrial, trialHistory]);
// Convert spec to ReactFlow edges with selection styling
const edges = useMemo(() => {
@@ -841,6 +811,39 @@ function SpecRendererInner({
<div className="absolute bottom-4 left-4 z-10 px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
<span className="text-sm text-dark-300">{spec.meta.study_name}</span>
</div>
{/* Progress indicator when running */}
{isRunning && wsProgress && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-3 px-4 py-2 bg-dark-800/95 backdrop-blur rounded-lg border border-dark-600 shadow-lg">
<ProgressRing
progress={wsProgress.percentage}
size={36}
strokeWidth={3}
color="#10b981"
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-white">
Trial {wsProgress.current} / {wsProgress.total}
</span>
<span className="text-xs text-dark-400">
{wsProgress.fea_count > 0 && `${wsProgress.fea_count} FEA`}
{wsProgress.fea_count > 0 && wsProgress.nn_count > 0 && ' + '}
{wsProgress.nn_count > 0 && `${wsProgress.nn_count} NN`}
{wsProgress.fea_count === 0 && wsProgress.nn_count === 0 && 'Running...'}
</span>
</div>
{wsBestTrial && (
<div className="flex flex-col border-l border-dark-600 pl-3 ml-1">
<span className="text-xs text-dark-400">Best</span>
<span className="text-sm font-medium text-emerald-400">
{typeof wsBestTrial.value === 'number'
? wsBestTrial.value.toFixed(4)
: wsBestTrial.value}
</span>
</div>
)}
</div>
)}
</div>
);
}