feat: Add panel management, validation, and error handling to canvas

Phase 1 - Panel Management System:
- Create usePanelStore.ts for centralized panel state management
- Add PanelContainer.tsx for draggable floating panels
- Create FloatingIntrospectionPanel.tsx (persistent, doesn't disappear on node click)
- Create ResultsPanel.tsx for trial result details
- Refactor NodeConfigPanelV2 to use panel store for introspection
- Integrate PanelContainer into CanvasView

Phase 2 - Pre-run Validation:
- Create specValidator.ts with comprehensive validation rules
- Add ValidationPanel (enhanced version with error navigation)
- Add Validate button to SpecRenderer with status indicator
- Block run if validation fails
- Check for: design vars, objectives, extractors, bounds, connections

Phase 3 - Error Handling & Recovery:
- Create ErrorPanel.tsx for displaying optimization errors
- Add error classification (nx_crash, solver_fail, extractor_error, etc.)
- Add recovery suggestions based on error type
- Update status endpoint to return error info
- Add _get_study_error_info helper to check error_status.json and DB
- Integrate error detection into status polling

Documentation:
- Add CANVAS_ROBUSTNESS_PLAN.md with full implementation plan
This commit is contained in:
2026-01-21 21:35:31 -05:00
parent e1c59a51c1
commit c224b16ac3
12 changed files with 2853 additions and 29 deletions

View File

@@ -11,7 +11,7 @@
*/
import { useCallback, useRef, useEffect, useMemo, useState, DragEvent } from 'react';
import { Play, Square, Loader2, Eye, EyeOff } from 'lucide-react';
import { Play, Square, Loader2, Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react';
import ReactFlow, {
Background,
Controls,
@@ -38,8 +38,10 @@ import {
useSelectedEdgeId,
} from '../../hooks/useSpecStore';
import { useSpecWebSocket } from '../../hooks/useSpecWebSocket';
import { usePanelStore } from '../../hooks/usePanelStore';
import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
import { CanvasNodeData } from '../../lib/canvas/schema';
import { validateSpec, canRunOptimization } from '../../lib/validation/specValidator';
// ============================================================================
// Drag-Drop Helpers
@@ -202,11 +204,18 @@ function SpecRendererInner({
const wsStudyId = enableWebSocket ? storeStudyId : null;
const { status: wsStatus } = useSpecWebSocket(wsStudyId);
// Panel store for validation and error panels
const { setValidationData, addError, openPanel } = usePanelStore();
// Optimization execution state
const [isRunning, setIsRunning] = useState(false);
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(() => {
@@ -226,19 +235,119 @@ function SpecRendererInner({
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
// Silent fail on polling - network issues shouldn't spam errors
console.debug('Status poll failed:', e);
}
};
checkStatus();
const interval = setInterval(checkStatus, 3000);
return () => clearInterval(interval);
}, [studyId]);
}, [studyId, addError]);
// Validate the spec and show results in panel
const handleValidate = useCallback(() => {
if (!spec) return;
const result = validateSpec(spec);
setValidationData(result);
setValidationStatus(result.valid ? 'valid' : 'invalid');
// Auto-open validation panel if there are issues
if (!result.valid || result.warnings.length > 0) {
openPanel('validation');
}
return result;
}, [spec, setValidationData, openPanel]);
const handleRun = async () => {
if (!studyId) return;
if (!studyId || !spec) return;
// Validate before running
const validation = handleValidate();
if (!validation || !validation.valid) {
// Show validation panel with errors
return;
}
// Also do a quick sanity check
const { canRun, reason } = canRunOptimization(spec);
if (!canRun) {
addError({
type: 'config_error',
message: reason || 'Cannot run optimization',
recoverable: false,
suggestions: ['Check the validation panel for details'],
timestamp: Date.now(),
});
return;
}
setIsStarting(true);
try {
const res = await fetch(`/api/optimization/studies/${studyId}/run`, {
@@ -251,8 +360,19 @@ function SpecRendererInner({
throw new Error(err.detail || 'Failed to start');
}
setIsRunning(true);
setValidationStatus('unchecked'); // Clear validation status when running
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start optimization');
const errorMessage = e instanceof Error ? e.message : 'Failed to start optimization';
setError(errorMessage);
// Also add to error panel for persistence
addError({
type: 'system_error',
message: errorMessage,
recoverable: true,
suggestions: ['Check if the backend is running', 'Verify the study configuration'],
timestamp: Date.now(),
});
} finally {
setIsStarting(false);
}
@@ -261,10 +381,22 @@ function SpecRendererInner({
const handleStop = async () => {
if (!studyId) return;
try {
await fetch(`/api/optimization/studies/${studyId}/stop`, { method: 'POST' });
const res = await fetch(`/api/optimization/studies/${studyId}/stop`, { method: 'POST' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to stop');
}
setIsRunning(false);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to stop optimization');
const errorMessage = e instanceof Error ? e.message : 'Failed to stop optimization';
setError(errorMessage);
addError({
type: 'system_error',
message: errorMessage,
recoverable: false,
suggestions: ['The optimization may still be running in the background'],
timestamp: Date.now(),
});
}
};
@@ -637,6 +769,7 @@ function SpecRendererInner({
{/* Action Buttons */}
<div className="absolute bottom-4 right-4 z-10 flex gap-2">
{/* Results toggle */}
{bestTrial && (
<button
onClick={() => setShowResults(!showResults)}
@@ -652,26 +785,54 @@ function SpecRendererInner({
</button>
)}
{/* Validate button - shows validation status */}
<button
onClick={handleValidate}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors border ${
validationStatus === 'valid'
? 'bg-green-600/20 text-green-400 border-green-500/50 hover:bg-green-600/30'
: validationStatus === 'invalid'
? 'bg-red-600/20 text-red-400 border-red-500/50 hover:bg-red-600/30'
: 'bg-dark-800 text-dark-300 border-dark-600 hover:text-white hover:border-dark-500'
}`}
title="Validate spec before running"
>
{validationStatus === 'valid' ? (
<CheckCircle size={16} />
) : validationStatus === 'invalid' ? (
<AlertCircle size={16} />
) : (
<CheckCircle size={16} />
)}
<span className="text-sm font-medium">Validate</span>
</button>
{/* Run/Stop button */}
{isRunning ? (
<button
onClick={handleStop}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 shadow-lg transition-colors font-medium"
>
<Square size={16} fill="currentColor" />
Stop Optimization
Stop
</button>
) : (
<button
onClick={handleRun}
disabled={isStarting}
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-500 shadow-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isStarting || validationStatus === 'invalid'}
className={`flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-colors font-medium ${
validationStatus === 'invalid'
? 'bg-dark-700 text-dark-400 cursor-not-allowed'
: 'bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed'
}`}
title={validationStatus === 'invalid' ? 'Fix validation errors first' : 'Start optimization'}
>
{isStarting ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Play size={16} fill="currentColor" />
)}
Run Optimization
Run
</button>
)}
</div>