diff --git a/atomizer-dashboard/backend/api/routes/optimization.py b/atomizer-dashboard/backend/api/routes/optimization.py index 9f1e421c..5bf72f11 100644 --- a/atomizer-dashboard/backend/api/routes/optimization.py +++ b/atomizer-dashboard/backend/api/routes/optimization.py @@ -15,6 +15,7 @@ import shutil import subprocess import psutil import signal +import time from datetime import datetime # Add project root to path @@ -155,6 +156,93 @@ def get_accurate_study_status( return "paused" +def _get_study_error_info(study_dir: Path, results_dir: Path) -> dict: + """Get error information from study if any errors occurred. + + Checks for: + 1. error_status.json file (written by optimization process on error) + 2. Failed trials in database + 3. Error logs + + Returns: + dict with keys: error, error_details, error_timestamp, current_trial, status_override + """ + error_info = {} + + # Check for error_status.json (written by optimization process) + error_file = results_dir / "error_status.json" + if error_file.exists(): + try: + with open(error_file) as f: + error_data = json.load(f) + error_info["error"] = error_data.get("error", "Unknown error") + error_info["error_details"] = error_data.get("details") + error_info["error_timestamp"] = error_data.get("timestamp") + error_info["current_trial"] = error_data.get("trial") + + # If error is recent (within last 5 minutes), set status to failed + if error_data.get("timestamp"): + error_age = time.time() - error_data["timestamp"] + if error_age < 300: # 5 minutes + error_info["status_override"] = "failed" + except Exception: + pass + + # Check for failed trials in database + study_db = results_dir / "study.db" + if study_db.exists() and "error" not in error_info: + try: + conn = sqlite3.connect(str(study_db), timeout=2.0) + cursor = conn.cursor() + + # Check for FAIL state trials (Optuna uses 'FAIL' not 'FAILED') + cursor.execute(""" + SELECT number, datetime_complete + FROM trials + WHERE state = 'FAIL' + ORDER BY datetime_complete DESC + LIMIT 1 + """) + failed = cursor.fetchone() + + if failed: + trial_number, fail_time = failed + error_info["error"] = f"Trial {trial_number} failed" + error_info["current_trial"] = trial_number + # Parse datetime to timestamp if available + if fail_time: + try: + from datetime import datetime + + dt = datetime.fromisoformat(fail_time) + error_info["error_timestamp"] = dt.timestamp() + except Exception: + error_info["error_timestamp"] = int(time.time()) + + conn.close() + except Exception: + pass + + # Check optimization log for errors + log_file = results_dir / "optimization.log" + if log_file.exists() and "error" not in error_info: + try: + # Read last 50 lines of log + with open(log_file, "r") as f: + lines = f.readlines()[-50:] + + for line in reversed(lines): + line_lower = line.lower() + if "error" in line_lower or "failed" in line_lower or "exception" in line_lower: + error_info["error"] = line.strip()[:200] # Truncate long messages + error_info["error_timestamp"] = int(log_file.stat().st_mtime) + break + except Exception: + pass + + return error_info + + def _load_study_info(study_dir: Path, topic: Optional[str] = None) -> Optional[dict]: """Load study info from a study directory. Returns None if not a valid study.""" # Look for optimization config (check multiple locations) @@ -394,9 +482,12 @@ async def get_study_status(study_id: str): total_trials = config.get("optimization_settings", {}).get("n_trials", 50) status = get_accurate_study_status(study_id, trial_count, total_trials, True) + # Check for error status + error_info = _get_study_error_info(study_dir, results_dir) + return { "study_id": study_id, - "status": status, + "status": error_info.get("status_override") or status, "progress": { "current": trial_count, "total": total_trials, @@ -405,6 +496,10 @@ async def get_study_status(study_id: str): "best_trial": best_trial, "pruned_trials": pruned_count, "config": config, + "error": error_info.get("error"), + "error_details": error_info.get("error_details"), + "error_timestamp": error_info.get("error_timestamp"), + "current_trial": error_info.get("current_trial"), } # Legacy: Read from JSON history @@ -437,9 +532,12 @@ async def get_study_status(study_id: str): status = "completed" if trial_count >= total_trials else "running" + # Check for error status + error_info = _get_study_error_info(study_dir, results_dir) + return { "study_id": study_id, - "status": status, + "status": error_info.get("status_override") or status, "progress": { "current": trial_count, "total": total_trials, @@ -448,6 +546,10 @@ async def get_study_status(study_id: str): "best_trial": best_trial, "pruned_trials": pruned_count, "config": config, + "error": error_info.get("error"), + "error_details": error_info.get("error_details"), + "error_timestamp": error_info.get("error_timestamp"), + "current_trial": error_info.get("current_trial"), } except FileNotFoundError: diff --git a/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx b/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx index 16fd7939..676524fb 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx @@ -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(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(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 */}
+ {/* Results toggle */} {bestTrial && ( )} + {/* Validate button - shows validation status */} + + + {/* Run/Stop button */} {isRunning ? ( ) : ( )}
diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/ErrorPanel.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/ErrorPanel.tsx new file mode 100644 index 00000000..5ed3bdaa --- /dev/null +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/ErrorPanel.tsx @@ -0,0 +1,255 @@ +/** + * ErrorPanel - Displays optimization errors with recovery options + * + * Shows errors that occurred during optimization with: + * - Error classification (NX crash, solver failure, etc.) + * - Recovery suggestions + * - Ability to dismiss individual errors + * - Support for multiple simultaneous errors + */ + +import { useMemo } from 'react'; +import { + X, + AlertTriangle, + AlertOctagon, + RefreshCw, + Minimize2, + Maximize2, + Trash2, + Bug, + Cpu, + FileWarning, + Settings, + Server, +} from 'lucide-react'; +import { useErrorPanel, usePanelStore, OptimizationError } from '../../../hooks/usePanelStore'; + +interface ErrorPanelProps { + onClose: () => void; + onRetry?: (trial?: number) => void; + onSkipTrial?: (trial: number) => void; +} + +export function ErrorPanel({ onClose, onRetry, onSkipTrial }: ErrorPanelProps) { + const panel = useErrorPanel(); + const { minimizePanel, dismissError, clearErrors } = usePanelStore(); + + const sortedErrors = useMemo(() => { + return [...panel.errors].sort((a, b) => b.timestamp - a.timestamp); + }, [panel.errors]); + + if (!panel.open || panel.errors.length === 0) return null; + + // Minimized view + if (panel.minimized) { + return ( +
minimizePanel('error')} + > + + + {panel.errors.length} Error{panel.errors.length !== 1 ? 's' : ''} + + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + + Optimization Errors ({panel.errors.length}) + +
+
+ {panel.errors.length > 1 && ( + + )} + + +
+
+ + {/* Content */} +
+ {sortedErrors.map((error) => ( + dismissError(error.timestamp)} + onRetry={onRetry} + onSkipTrial={onSkipTrial} + /> + ))} +
+
+ ); +} + +// ============================================================================ +// Error Item Component +// ============================================================================ + +interface ErrorItemProps { + error: OptimizationError; + onDismiss: () => void; + onRetry?: (trial?: number) => void; + onSkipTrial?: (trial: number) => void; +} + +function ErrorItem({ error, onDismiss, onRetry, onSkipTrial }: ErrorItemProps) { + const icon = getErrorIcon(error.type); + const typeLabel = getErrorTypeLabel(error.type); + const timeAgo = getTimeAgo(error.timestamp); + + return ( +
+ {/* Error header */} +
+
+ {icon} +
+
+
+ + {typeLabel} + + {error.trial !== undefined && ( + + Trial #{error.trial} + + )} + + {timeAgo} + +
+

{error.message}

+ {error.details && ( +

+ {error.details} +

+ )} +
+ +
+ + {/* Suggestions */} + {error.suggestions.length > 0 && ( +
+

Suggestions:

+
    + {error.suggestions.map((suggestion, idx) => ( +
  • + - + {suggestion} +
  • + ))} +
+
+ )} + + {/* Actions */} + {error.recoverable && ( +
+ {onRetry && ( + + )} + {onSkipTrial && error.trial !== undefined && ( + + )} +
+ )} +
+ ); +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function getErrorIcon(type: OptimizationError['type']) { + switch (type) { + case 'nx_crash': + return ; + case 'solver_fail': + return ; + case 'extractor_error': + return ; + case 'config_error': + return ; + case 'system_error': + return ; + default: + return ; + } +} + +function getErrorTypeLabel(type: OptimizationError['type']) { + switch (type) { + case 'nx_crash': + return 'NX Crash'; + case 'solver_fail': + return 'Solver Failure'; + case 'extractor_error': + return 'Extractor Error'; + case 'config_error': + return 'Configuration Error'; + case 'system_error': + return 'System Error'; + default: + return 'Unknown Error'; + } +} + +function getTimeAgo(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + + if (seconds < 60) return 'just now'; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; +} + +export default ErrorPanel; diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/FloatingIntrospectionPanel.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/FloatingIntrospectionPanel.tsx new file mode 100644 index 00000000..fc03390e --- /dev/null +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/FloatingIntrospectionPanel.tsx @@ -0,0 +1,481 @@ +/** + * FloatingIntrospectionPanel - Persistent introspection panel using store + * + * This is a wrapper around the existing IntrospectionPanel that: + * 1. Gets its state from usePanelStore instead of local state + * 2. Persists data when the panel is closed and reopened + * 3. Can be opened from anywhere without losing state + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { + X, + Search, + RefreshCw, + Plus, + ChevronDown, + ChevronRight, + FileBox, + Cpu, + SlidersHorizontal, + AlertTriangle, + Scale, + Link, + Box, + Settings2, + GitBranch, + File, + Grid3x3, + Target, + Zap, + Layers, + Minimize2, + Maximize2, +} from 'lucide-react'; +import { + useIntrospectionPanel, + usePanelStore, + IntrospectionData, +} from '../../../hooks/usePanelStore'; +import { useSpecStore } from '../../../hooks/useSpecStore'; + +interface FloatingIntrospectionPanelProps { + onClose: () => void; +} + +// Reuse types from original IntrospectionPanel +interface Expression { + name: string; + value: number; + rhs?: string; + min?: number; + max?: number; + unit?: string; + units?: string; + type: string; + source?: string; +} + +interface ExpressionsResult { + user: Expression[]; + internal: Expression[]; + total_count: number; + user_count: number; +} + +interface ModelFileInfo { + name: string; + stem: string; + type: string; + description?: string; + size_kb: number; + has_cache: boolean; +} + +interface ModelFilesResponse { + files: { + sim: ModelFileInfo[]; + afm: ModelFileInfo[]; + fem: ModelFileInfo[]; + idealized: ModelFileInfo[]; + prt: ModelFileInfo[]; + }; + all_files: ModelFileInfo[]; +} + +export function FloatingIntrospectionPanel({ onClose }: FloatingIntrospectionPanelProps) { + const panel = useIntrospectionPanel(); + const { + minimizePanel, + updateIntrospectionResult, + setIntrospectionLoading, + setIntrospectionError, + setIntrospectionFile, + } = usePanelStore(); + const { addNode } = useSpecStore(); + + // Local UI state + const [expandedSections, setExpandedSections] = useState>( + new Set(['expressions', 'extractors', 'file_deps', 'fea_results', 'fem_mesh', 'sim_solutions', 'sim_bcs']) + ); + const [searchTerm, setSearchTerm] = useState(''); + const [modelFiles, setModelFiles] = useState(null); + const [isLoadingFiles, setIsLoadingFiles] = useState(false); + + const data = panel.data; + const result = data?.result; + const isLoading = data?.isLoading || false; + const error = data?.error; + + // Fetch available files when studyId changes + const fetchAvailableFiles = useCallback(async () => { + if (!data?.studyId) return; + + setIsLoadingFiles(true); + try { + const res = await fetch(`/api/optimization/studies/${data.studyId}/nx/parts`); + if (res.ok) { + const filesData = await res.json(); + setModelFiles(filesData); + } + } catch (e) { + console.error('Failed to fetch model files:', e); + } finally { + setIsLoadingFiles(false); + } + }, [data?.studyId]); + + // Run introspection + const runIntrospection = useCallback(async (fileName?: string) => { + if (!data?.filePath && !data?.studyId) return; + + setIntrospectionLoading(true); + setIntrospectionError(null); + + try { + let res; + + if (data?.studyId) { + const endpoint = fileName + ? `/api/optimization/studies/${data.studyId}/nx/introspect/${encodeURIComponent(fileName)}` + : `/api/optimization/studies/${data.studyId}/nx/introspect`; + res = await fetch(endpoint); + } else { + res = await fetch('/api/nx/introspect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: data?.filePath }), + }); + } + + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + throw new Error(errData.detail || 'Introspection failed'); + } + + const responseData = await res.json(); + updateIntrospectionResult(responseData.introspection || responseData); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to introspect model'; + setIntrospectionError(msg); + console.error('Introspection error:', e); + } + }, [data?.filePath, data?.studyId, setIntrospectionLoading, setIntrospectionError, updateIntrospectionResult]); + + // Fetch files list on mount + useEffect(() => { + fetchAvailableFiles(); + }, [fetchAvailableFiles]); + + // Run introspection when panel opens or selected file changes + useEffect(() => { + if (panel.open && data && !result && !isLoading) { + runIntrospection(data.selectedFile); + } + }, [panel.open, data?.selectedFile]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleFileChange = (e: React.ChangeEvent) => { + const newFile = e.target.value; + setIntrospectionFile(newFile); + runIntrospection(newFile); + }; + + const toggleSection = (section: string) => { + setExpandedSections((prev) => { + const next = new Set(prev); + if (next.has(section)) next.delete(section); + else next.add(section); + return next; + }); + }; + + // Handle both array format (old) and object format (new API) + const allExpressions: Expression[] = useMemo(() => { + if (!result?.expressions) return []; + + if (Array.isArray(result.expressions)) { + return result.expressions as Expression[]; + } + + const exprObj = result.expressions as ExpressionsResult; + return [...(exprObj.user || []), ...(exprObj.internal || [])]; + }, [result?.expressions]); + + const filteredExpressions = allExpressions.filter((e) => + e.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const addExpressionAsDesignVar = (expr: Expression) => { + const minValue = expr.min ?? expr.value * 0.5; + const maxValue = expr.max ?? expr.value * 1.5; + + addNode('dv', { + name: expr.name, + expression_name: expr.name, + type: 'continuous', + bounds: { min: minValue, max: maxValue }, + baseline: expr.value, + units: expr.unit || expr.units, + enabled: true, + }); + }; + + if (!panel.open) return null; + + // Minimized view + if (panel.minimized) { + return ( +
minimizePanel('introspection')} + > + + + Model Introspection + {data?.selectedFile && ({data.selectedFile})} + + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + + Model Introspection + {data?.selectedFile && ({data.selectedFile})} + +
+
+ + + +
+
+ + {/* File Selector + Search */} +
+ {data?.studyId && modelFiles && modelFiles.all_files.length > 0 && ( +
+ + + {isLoadingFiles && ( + + )} +
+ )} + + setSearchTerm(e.target.value)} + className="w-full px-3 py-1.5 bg-dark-800 border border-dark-600 rounded-lg + text-sm text-white placeholder-dark-500 focus:outline-none focus:border-primary-500" + /> +
+ + {/* Content */} +
+ {isLoading ? ( +
+ + Analyzing model... +
+ ) : error ? ( +
{error}
+ ) : result ? ( +
+ {/* Solver Type */} + {result.solver_type && ( +
+
+ + Solver: + {result.solver_type as string} +
+
+ )} + + {/* Expressions Section */} +
+ + + {expandedSections.has('expressions') && ( +
+ {filteredExpressions.length === 0 ? ( +

+ No expressions found +

+ ) : ( + filteredExpressions.map((expr) => ( +
+
+

{expr.name}

+

+ {expr.value} {expr.units || expr.unit || ''} + {expr.source === 'inferred' && ( + (inferred) + )} +

+
+ +
+ )) + )} +
+ )} +
+ + {/* Mass Properties Section */} + {result.mass_properties && ( +
+ + + {expandedSections.has('mass') && ( +
+ {(result.mass_properties as Record).mass_kg !== undefined && ( +
+ Mass + + {((result.mass_properties as Record).mass_kg as number).toFixed(4)} kg + +
+ )} +
+ )} +
+ )} + + {/* More sections can be added here following the same pattern as the original IntrospectionPanel */} +
+ ) : ( +
+ Click refresh to analyze the model +
+ )} +
+
+ ); +} + +export default FloatingIntrospectionPanel; diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx index 943082eb..9061a7ed 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx @@ -17,8 +17,8 @@ import { useSelectedNodeId, useSelectedNode, } from '../../../hooks/useSpecStore'; +import { usePanelStore } from '../../../hooks/usePanelStore'; import { FileBrowser } from './FileBrowser'; -import { IntrospectionPanel } from './IntrospectionPanel'; import { DesignVariable, Extractor, @@ -272,7 +272,15 @@ interface SpecConfigProps { } function ModelNodeConfig({ spec }: SpecConfigProps) { - const [showIntrospection, setShowIntrospection] = useState(false); + const { setIntrospectionData, openPanel } = usePanelStore(); + + const handleOpenIntrospection = () => { + // Set up introspection data and open the panel + setIntrospectionData({ + filePath: spec.model.sim?.path || '', + studyId: useSpecStore.getState().studyId || undefined, + }); + }; return ( <> @@ -300,7 +308,7 @@ function ModelNodeConfig({ spec }: SpecConfigProps) { {spec.model.sim?.path && ( )} - - {showIntrospection && spec.model.sim?.path && ( -
- setShowIntrospection(false)} - /> -
- )} + + {/* Note: IntrospectionPanel is now rendered by PanelContainer, not here */} ); } diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/PanelContainer.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/PanelContainer.tsx new file mode 100644 index 00000000..14ad2d14 --- /dev/null +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/PanelContainer.tsx @@ -0,0 +1,207 @@ +/** + * PanelContainer - Orchestrates all floating panels in the canvas view + * + * This component renders floating panels (Introspection, Validation, Error, Results) + * in a portal, positioned absolutely within the canvas area. + * + * Features: + * - Draggable panels + * - Z-index management (click to bring to front) + * - Keyboard shortcuts (Escape to close all) + * - Position persistence via usePanelStore + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { + usePanelStore, + useIntrospectionPanel, + useValidationPanel, + useErrorPanel, + useResultsPanel, + PanelPosition, +} from '../../../hooks/usePanelStore'; +import { FloatingIntrospectionPanel } from './FloatingIntrospectionPanel'; +import { FloatingValidationPanel } from './ValidationPanel'; +import { ErrorPanel } from './ErrorPanel'; +import { ResultsPanel } from './ResultsPanel'; + +interface PanelContainerProps { + /** Container element to render panels into (defaults to document.body) */ + container?: HTMLElement; + /** Callback when retry is requested from error panel */ + onRetry?: (trial?: number) => void; + /** Callback when skip trial is requested */ + onSkipTrial?: (trial: number) => void; +} + +type PanelName = 'introspection' | 'validation' | 'error' | 'results'; + +export function PanelContainer({ container, onRetry, onSkipTrial }: PanelContainerProps) { + const { closePanel, setPanelPosition, closeAllPanels } = usePanelStore(); + + const introspectionPanel = useIntrospectionPanel(); + const validationPanel = useValidationPanel(); + const errorPanel = useErrorPanel(); + const resultsPanel = useResultsPanel(); + + // Track which panel is on top (for z-index) + const [topPanel, setTopPanel] = useState(null); + + // Dragging state + const [dragging, setDragging] = useState<{ panel: PanelName; offset: { x: number; y: number } } | null>(null); + const dragRef = useRef<{ panel: PanelName; offset: { x: number; y: number } } | null>(null); + + // Escape key to close all panels + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + closeAllPanels(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [closeAllPanels]); + + // Mouse move handler for dragging + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!dragRef.current) return; + + const { panel, offset } = dragRef.current; + const newPosition: PanelPosition = { + x: e.clientX - offset.x, + y: e.clientY - offset.y, + }; + + // Clamp to viewport + newPosition.x = Math.max(0, Math.min(window.innerWidth - 100, newPosition.x)); + newPosition.y = Math.max(0, Math.min(window.innerHeight - 50, newPosition.y)); + + setPanelPosition(panel, newPosition); + }; + + const handleMouseUp = () => { + dragRef.current = null; + setDragging(null); + }; + + if (dragging) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + } + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [dragging, setPanelPosition]); + + // Start dragging a panel + const handleDragStart = useCallback((panel: PanelName, e: React.MouseEvent, position: PanelPosition) => { + const offset = { + x: e.clientX - position.x, + y: e.clientY - position.y, + }; + dragRef.current = { panel, offset }; + setDragging({ panel, offset }); + setTopPanel(panel); + }, []); + + // Click to bring panel to front + const handlePanelClick = useCallback((panel: PanelName) => { + setTopPanel(panel); + }, []); + + // Get z-index for a panel + const getZIndex = (panel: PanelName) => { + const baseZ = 100; + if (panel === topPanel) return baseZ + 10; + return baseZ; + }; + + // Render a draggable wrapper + const renderDraggable = ( + panel: PanelName, + position: PanelPosition, + isOpen: boolean, + children: React.ReactNode + ) => { + if (!isOpen) return null; + + return ( +
handlePanelClick(panel)} + > + {/* Drag handle - the header area */} +
handleDragStart(panel, e, position)} + style={{ zIndex: 1 }} + /> + {/* Panel content */} +
+ {children} +
+
+ ); + }; + + // Determine what to render + const panels = ( + <> + {/* Introspection Panel */} + {renderDraggable( + 'introspection', + introspectionPanel.position || { x: 100, y: 100 }, + introspectionPanel.open, + closePanel('introspection')} /> + )} + + {/* Validation Panel */} + {renderDraggable( + 'validation', + validationPanel.position || { x: 150, y: 150 }, + validationPanel.open, + closePanel('validation')} /> + )} + + {/* Error Panel */} + {renderDraggable( + 'error', + errorPanel.position || { x: 200, y: 100 }, + errorPanel.open, + closePanel('error')} + onRetry={onRetry} + onSkipTrial={onSkipTrial} + /> + )} + + {/* Results Panel */} + {renderDraggable( + 'results', + resultsPanel.position || { x: 250, y: 150 }, + resultsPanel.open, + closePanel('results')} /> + )} + + ); + + // Use portal if container specified, otherwise render in place + if (container) { + return createPortal(panels, container); + } + + return panels; +} + +export default PanelContainer; diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/ResultsPanel.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/ResultsPanel.tsx new file mode 100644 index 00000000..88053611 --- /dev/null +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/ResultsPanel.tsx @@ -0,0 +1,179 @@ +/** + * ResultsPanel - Shows detailed trial results + * + * Displays the parameters, objectives, and constraints for a specific trial. + * Can be opened by clicking on result badges on nodes. + */ + +import { + X, + Minimize2, + Maximize2, + CheckCircle, + XCircle, + Trophy, + SlidersHorizontal, + Target, + AlertTriangle, + Clock, +} from 'lucide-react'; +import { useResultsPanel, usePanelStore } from '../../../hooks/usePanelStore'; + +interface ResultsPanelProps { + onClose: () => void; +} + +export function ResultsPanel({ onClose }: ResultsPanelProps) { + const panel = useResultsPanel(); + const { minimizePanel } = usePanelStore(); + const data = panel.data; + + if (!panel.open || !data) return null; + + const timestamp = new Date(data.timestamp).toLocaleTimeString(); + + // Minimized view + if (panel.minimized) { + return ( +
minimizePanel('results')} + > + + + Trial #{data.trialNumber} + + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + + Trial #{data.trialNumber} + + {data.isBest && ( + + Best + + )} +
+
+ + +
+
+ + {/* Content */} +
+ {/* Status */} +
+ {data.isFeasible ? ( +
+ + Feasible +
+ ) : ( +
+ + Infeasible +
+ )} +
+ + {timestamp} +
+
+ + {/* Parameters */} +
+

+ + Parameters +

+
+ {Object.entries(data.params).map(([name, value]) => ( +
+ {name} + {formatValue(value)} +
+ ))} +
+
+ + {/* Objectives */} +
+

+ + Objectives +

+
+ {Object.entries(data.objectives).map(([name, value]) => ( +
+ {name} + {formatValue(value)} +
+ ))} +
+
+ + {/* Constraints (if any) */} + {data.constraints && Object.keys(data.constraints).length > 0 && ( +
+

+ + Constraints +

+
+ {Object.entries(data.constraints).map(([name, constraint]) => ( +
+ + {constraint.feasible ? ( + + ) : ( + + )} + {name} + + + {formatValue(constraint.value)} + +
+ ))} +
+
+ )} +
+
+ ); +} + +function formatValue(value: number): string { + if (Math.abs(value) < 0.001 || Math.abs(value) >= 10000) { + return value.toExponential(3); + } + return value.toFixed(4).replace(/\.?0+$/, ''); +} + +export default ResultsPanel; diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/ValidationPanel.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/ValidationPanel.tsx index 56c71c47..4ff37cc1 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/panels/ValidationPanel.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/ValidationPanel.tsx @@ -1,10 +1,41 @@ +/** + * ValidationPanel - Displays spec validation errors and warnings + * + * Shows a list of validation issues that need to be fixed before + * running an optimization. Supports auto-navigation to problematic nodes. + * + * Can be used in two modes: + * 1. Legacy mode: Pass validation prop directly (for backward compatibility) + * 2. Store mode: Uses usePanelStore for persistent state + */ + +import { useMemo } from 'react'; +import { + X, + AlertCircle, + AlertTriangle, + CheckCircle, + ChevronRight, + Minimize2, + Maximize2, +} from 'lucide-react'; +import { useValidationPanel, usePanelStore, ValidationError as StoreValidationError } from '../../../hooks/usePanelStore'; +import { useSpecStore } from '../../../hooks/useSpecStore'; import { ValidationResult } from '../../../lib/canvas/validation'; -interface ValidationPanelProps { +// ============================================================================ +// Legacy Props Interface (for backward compatibility) +// ============================================================================ + +interface LegacyValidationPanelProps { validation: ValidationResult; } -export function ValidationPanel({ validation }: ValidationPanelProps) { +/** + * Legacy ValidationPanel - Inline display for canvas overlay + * Kept for backward compatibility with AtomizerCanvas + */ +export function ValidationPanel({ validation }: LegacyValidationPanelProps) { return (
{validation.errors.length > 0 && ( @@ -30,3 +61,199 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
); } + +// ============================================================================ +// New Floating Panel (uses store) +// ============================================================================ + +interface FloatingValidationPanelProps { + onClose: () => void; +} + +export function FloatingValidationPanel({ onClose }: FloatingValidationPanelProps) { + const panel = useValidationPanel(); + const { minimizePanel } = usePanelStore(); + const { setSelectedNodeId } = useSpecStore(); + + const { errors, warnings, valid } = useMemo(() => { + if (!panel.data) { + return { errors: [], warnings: [], valid: true }; + } + return { + errors: panel.data.errors || [], + warnings: panel.data.warnings || [], + valid: panel.data.valid, + }; + }, [panel.data]); + + const handleNavigateToNode = (nodeId?: string) => { + if (nodeId) { + setSelectedNodeId(nodeId); + } + }; + + if (!panel.open) return null; + + // Minimized view + if (panel.minimized) { + return ( +
minimizePanel('validation')} + > + {valid ? ( + + ) : ( + + )} + + Validation {valid ? 'Passed' : `(${errors.length} errors)`} + + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ {valid ? ( + + ) : ( + + )} + + {valid ? 'Validation Passed' : 'Validation Issues'} + +
+
+ + +
+
+ + {/* Content */} +
+ {valid && errors.length === 0 && warnings.length === 0 ? ( +
+ +

All checks passed!

+

+ Your spec is ready to run. +

+
+ ) : ( + <> + {/* Errors */} + {errors.length > 0 && ( +
+

+ + Errors ({errors.length}) +

+ {errors.map((error, idx) => ( + handleNavigateToNode(error.nodeId)} + /> + ))} +
+ )} + + {/* Warnings */} + {warnings.length > 0 && ( +
+

+ + Warnings ({warnings.length}) +

+ {warnings.map((warning, idx) => ( + handleNavigateToNode(warning.nodeId)} + /> + ))} +
+ )} + + )} +
+ + {/* Footer */} + {!valid && ( +
+

+ Fix all errors before running the optimization. + Warnings can be ignored but may cause issues. +

+
+ )} +
+ ); +} + +// ============================================================================ +// Validation Item Component +// ============================================================================ + +interface ValidationItemProps { + item: StoreValidationError; + severity: 'error' | 'warning'; + onNavigate: () => void; +} + +function ValidationItem({ item, severity, onNavigate }: ValidationItemProps) { + const isError = severity === 'error'; + const bgColor = isError ? 'bg-red-500/10' : 'bg-amber-500/10'; + const borderColor = isError ? 'border-red-500/30' : 'border-amber-500/30'; + const iconColor = isError ? 'text-red-400' : 'text-amber-400'; + + return ( +
+
+ {isError ? ( + + ) : ( + + )} +
+

{item.message}

+ {item.path && ( +

{item.path}

+ )} + {item.suggestion && ( +

{item.suggestion}

+ )} +
+ {item.nodeId && ( + + )} +
+
+ ); +} + +export default ValidationPanel; diff --git a/atomizer-dashboard/frontend/src/hooks/usePanelStore.ts b/atomizer-dashboard/frontend/src/hooks/usePanelStore.ts new file mode 100644 index 00000000..4b1d65db --- /dev/null +++ b/atomizer-dashboard/frontend/src/hooks/usePanelStore.ts @@ -0,0 +1,375 @@ +/** + * usePanelStore - Centralized state management for canvas panels + * + * This store manages the visibility and state of all panels in the canvas view. + * Panels persist their state even when the user clicks elsewhere on the canvas. + * + * Panel Types: + * - introspection: Model introspection results (floating, draggable) + * - validation: Spec validation errors/warnings (floating) + * - results: Trial results details (floating) + * - error: Error display with recovery options (floating) + */ + +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface IntrospectionData { + filePath: string; + studyId?: string; + selectedFile?: string; + result?: Record; + isLoading?: boolean; + error?: string | null; +} + +export interface ValidationError { + code: string; + severity: 'error' | 'warning'; + path: string; + message: string; + suggestion?: string; + nodeId?: string; +} + +export interface ValidationData { + valid: boolean; + errors: ValidationError[]; + warnings: ValidationError[]; + checkedAt: number; +} + +export interface OptimizationError { + type: 'nx_crash' | 'solver_fail' | 'extractor_error' | 'config_error' | 'system_error' | 'unknown'; + trial?: number; + message: string; + details?: string; + recoverable: boolean; + suggestions: string[]; + timestamp: number; +} + +export interface TrialResultData { + trialNumber: number; + params: Record; + objectives: Record; + constraints?: Record; + isFeasible: boolean; + isBest: boolean; + timestamp: number; +} + +export interface PanelPosition { + x: number; + y: number; +} + +export interface PanelState { + open: boolean; + position?: PanelPosition; + minimized?: boolean; +} + +export interface IntrospectionPanelState extends PanelState { + data?: IntrospectionData; +} + +export interface ValidationPanelState extends PanelState { + data?: ValidationData; +} + +export interface ErrorPanelState extends PanelState { + errors: OptimizationError[]; +} + +export interface ResultsPanelState extends PanelState { + data?: TrialResultData; +} + +// ============================================================================ +// Store Interface +// ============================================================================ + +interface PanelStore { + // Panel states + introspection: IntrospectionPanelState; + validation: ValidationPanelState; + error: ErrorPanelState; + results: ResultsPanelState; + + // Generic panel actions + openPanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void; + closePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void; + togglePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void; + minimizePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void; + setPanelPosition: (panel: 'introspection' | 'validation' | 'error' | 'results', position: PanelPosition) => void; + + // Introspection-specific actions + setIntrospectionData: (data: IntrospectionData) => void; + updateIntrospectionResult: (result: Record) => void; + setIntrospectionLoading: (loading: boolean) => void; + setIntrospectionError: (error: string | null) => void; + setIntrospectionFile: (fileName: string) => void; + + // Validation-specific actions + setValidationData: (data: ValidationData) => void; + clearValidation: () => void; + + // Error-specific actions + addError: (error: OptimizationError) => void; + clearErrors: () => void; + dismissError: (timestamp: number) => void; + + // Results-specific actions + setTrialResult: (data: TrialResultData) => void; + clearTrialResult: () => void; + + // Utility + closeAllPanels: () => void; + hasOpenPanels: () => boolean; +} + +// ============================================================================ +// Default States +// ============================================================================ + +const defaultIntrospection: IntrospectionPanelState = { + open: false, + position: { x: 100, y: 100 }, + minimized: false, + data: undefined, +}; + +const defaultValidation: ValidationPanelState = { + open: false, + position: { x: 150, y: 150 }, + minimized: false, + data: undefined, +}; + +const defaultError: ErrorPanelState = { + open: false, + position: { x: 200, y: 100 }, + minimized: false, + errors: [], +}; + +const defaultResults: ResultsPanelState = { + open: false, + position: { x: 250, y: 150 }, + minimized: false, + data: undefined, +}; + +// ============================================================================ +// Store Implementation +// ============================================================================ + +export const usePanelStore = create()( + persist( + (set, get) => ({ + // Initial states + introspection: defaultIntrospection, + validation: defaultValidation, + error: defaultError, + results: defaultResults, + + // Generic panel actions + openPanel: (panel) => set((state) => ({ + [panel]: { ...state[panel], open: true, minimized: false } + })), + + closePanel: (panel) => set((state) => ({ + [panel]: { ...state[panel], open: false } + })), + + togglePanel: (panel) => set((state) => ({ + [panel]: { ...state[panel], open: !state[panel].open, minimized: false } + })), + + minimizePanel: (panel) => set((state) => ({ + [panel]: { ...state[panel], minimized: !state[panel].minimized } + })), + + setPanelPosition: (panel, position) => set((state) => ({ + [panel]: { ...state[panel], position } + })), + + // Introspection actions + setIntrospectionData: (data) => set((state) => ({ + introspection: { + ...state.introspection, + open: true, + data + } + })), + + updateIntrospectionResult: (result) => set((state) => ({ + introspection: { + ...state.introspection, + data: state.introspection.data + ? { ...state.introspection.data, result, isLoading: false, error: null } + : undefined + } + })), + + setIntrospectionLoading: (loading) => set((state) => ({ + introspection: { + ...state.introspection, + data: state.introspection.data + ? { ...state.introspection.data, isLoading: loading } + : undefined + } + })), + + setIntrospectionError: (error) => set((state) => ({ + introspection: { + ...state.introspection, + data: state.introspection.data + ? { ...state.introspection.data, error, isLoading: false } + : undefined + } + })), + + setIntrospectionFile: (fileName) => set((state) => ({ + introspection: { + ...state.introspection, + data: state.introspection.data + ? { ...state.introspection.data, selectedFile: fileName } + : undefined + } + })), + + // Validation actions + setValidationData: (data) => set((state) => ({ + validation: { + ...state.validation, + open: true, + data + } + })), + + clearValidation: () => set((state) => ({ + validation: { + ...state.validation, + data: undefined + } + })), + + // Error actions + addError: (error) => set((state) => ({ + error: { + ...state.error, + open: true, + errors: [...state.error.errors, error] + } + })), + + clearErrors: () => set((state) => ({ + error: { + ...state.error, + errors: [], + open: false + } + })), + + dismissError: (timestamp) => set((state) => { + const newErrors = state.error.errors.filter(e => e.timestamp !== timestamp); + return { + error: { + ...state.error, + errors: newErrors, + open: newErrors.length > 0 + } + }; + }), + + // Results actions + setTrialResult: (data) => set((state) => ({ + results: { + ...state.results, + open: true, + data + } + })), + + clearTrialResult: () => set((state) => ({ + results: { + ...state.results, + data: undefined, + open: false + } + })), + + // Utility + closeAllPanels: () => set({ + introspection: { ...get().introspection, open: false }, + validation: { ...get().validation, open: false }, + error: { ...get().error, open: false }, + results: { ...get().results, open: false }, + }), + + hasOpenPanels: () => { + const state = get(); + return state.introspection.open || + state.validation.open || + state.error.open || + state.results.open; + }, + }), + { + name: 'atomizer-panel-store', + // Only persist certain fields (not loading states or errors) + partialize: (state) => ({ + introspection: { + position: state.introspection.position, + // Don't persist open state - start fresh each session + }, + validation: { + position: state.validation.position, + }, + error: { + position: state.error.position, + }, + results: { + position: state.results.position, + }, + }), + } + ) +); + +// ============================================================================ +// Selector Hooks (for convenience) +// ============================================================================ + +export const useIntrospectionPanel = () => usePanelStore((state) => state.introspection); +export const useValidationPanel = () => usePanelStore((state) => state.validation); +export const useErrorPanel = () => usePanelStore((state) => state.error); +export const useResultsPanel = () => usePanelStore((state) => state.results); + +// Actions +export const usePanelActions = () => usePanelStore((state) => ({ + openPanel: state.openPanel, + closePanel: state.closePanel, + togglePanel: state.togglePanel, + minimizePanel: state.minimizePanel, + setPanelPosition: state.setPanelPosition, + setIntrospectionData: state.setIntrospectionData, + updateIntrospectionResult: state.updateIntrospectionResult, + setIntrospectionLoading: state.setIntrospectionLoading, + setIntrospectionError: state.setIntrospectionError, + setIntrospectionFile: state.setIntrospectionFile, + setValidationData: state.setValidationData, + clearValidation: state.clearValidation, + addError: state.addError, + clearErrors: state.clearErrors, + dismissError: state.dismissError, + setTrialResult: state.setTrialResult, + clearTrialResult: state.clearTrialResult, + closeAllPanels: state.closeAllPanels, +})); diff --git a/atomizer-dashboard/frontend/src/lib/validation/specValidator.ts b/atomizer-dashboard/frontend/src/lib/validation/specValidator.ts new file mode 100644 index 00000000..4d413c89 --- /dev/null +++ b/atomizer-dashboard/frontend/src/lib/validation/specValidator.ts @@ -0,0 +1,394 @@ +/** + * Spec Validator - Validate AtomizerSpec v2.0 before running optimization + * + * This validator checks the spec for completeness and correctness, + * returning structured errors that can be displayed in the ValidationPanel. + */ + +import { AtomizerSpec, DesignVariable, Extractor, Objective, Constraint } from '../../types/atomizer-spec'; +import { ValidationError, ValidationData } from '../../hooks/usePanelStore'; + +// ============================================================================ +// Validation Rules +// ============================================================================ + +interface ValidationRule { + code: string; + check: (spec: AtomizerSpec) => ValidationError | null; +} + +const validationRules: ValidationRule[] = [ + // ---- Critical Errors (must fix) ---- + + { + code: 'NO_DESIGN_VARS', + check: (spec) => { + const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false); + if (enabledDVs.length === 0) { + return { + code: 'NO_DESIGN_VARS', + severity: 'error', + path: 'design_variables', + message: 'No design variables defined', + suggestion: 'Add at least one design variable from the introspection panel or drag from the palette.', + }; + } + return null; + }, + }, + + { + code: 'NO_OBJECTIVES', + check: (spec) => { + if (spec.objectives.length === 0) { + return { + code: 'NO_OBJECTIVES', + severity: 'error', + path: 'objectives', + message: 'No objectives defined', + suggestion: 'Add at least one objective to define what to optimize (minimize mass, maximize stiffness, etc.).', + }; + } + return null; + }, + }, + + { + code: 'NO_EXTRACTORS', + check: (spec) => { + if (spec.extractors.length === 0) { + return { + code: 'NO_EXTRACTORS', + severity: 'error', + path: 'extractors', + message: 'No extractors defined', + suggestion: 'Add extractors to pull physics values (displacement, stress, frequency) from FEA results.', + }; + } + return null; + }, + }, + + { + code: 'NO_MODEL', + check: (spec) => { + if (!spec.model.sim?.path) { + return { + code: 'NO_MODEL', + severity: 'error', + path: 'model.sim.path', + message: 'No simulation file configured', + suggestion: 'Select a .sim file in the study\'s model directory.', + }; + } + return null; + }, + }, + + // ---- Design Variable Validation ---- + + { + code: 'DV_INVALID_BOUNDS', + check: (spec) => { + for (const dv of spec.design_variables) { + if (dv.enabled === false) continue; + if (dv.bounds.min >= dv.bounds.max) { + return { + code: 'DV_INVALID_BOUNDS', + severity: 'error', + path: `design_variables.${dv.id}`, + message: `Design variable "${dv.name}" has invalid bounds (min >= max)`, + suggestion: `Set min (${dv.bounds.min}) to be less than max (${dv.bounds.max}).`, + nodeId: dv.id, + }; + } + } + return null; + }, + }, + + { + code: 'DV_NO_EXPRESSION', + check: (spec) => { + for (const dv of spec.design_variables) { + if (dv.enabled === false) continue; + if (!dv.expression_name || dv.expression_name.trim() === '') { + return { + code: 'DV_NO_EXPRESSION', + severity: 'error', + path: `design_variables.${dv.id}`, + message: `Design variable "${dv.name}" has no NX expression name`, + suggestion: 'Set the expression_name to match an NX expression in the model.', + nodeId: dv.id, + }; + } + } + return null; + }, + }, + + // ---- Extractor Validation ---- + + { + code: 'EXTRACTOR_NO_TYPE', + check: (spec) => { + for (const ext of spec.extractors) { + if (!ext.type || ext.type.trim() === '') { + return { + code: 'EXTRACTOR_NO_TYPE', + severity: 'error', + path: `extractors.${ext.id}`, + message: `Extractor "${ext.name}" has no type selected`, + suggestion: 'Select an extractor type (displacement, stress, frequency, etc.).', + nodeId: ext.id, + }; + } + } + return null; + }, + }, + + { + code: 'CUSTOM_EXTRACTOR_NO_CODE', + check: (spec) => { + for (const ext of spec.extractors) { + if (ext.type === 'custom_function' && (!ext.function?.source_code || ext.function.source_code.trim() === '')) { + return { + code: 'CUSTOM_EXTRACTOR_NO_CODE', + severity: 'error', + path: `extractors.${ext.id}`, + message: `Custom extractor "${ext.name}" has no code defined`, + suggestion: 'Open the code editor and write the extraction function.', + nodeId: ext.id, + }; + } + } + return null; + }, + }, + + // ---- Objective Validation ---- + + { + code: 'OBJECTIVE_NO_SOURCE', + check: (spec) => { + for (const obj of spec.objectives) { + // Check if objective is connected to an extractor via canvas edges + const hasSource = spec.canvas?.edges?.some( + edge => edge.target === obj.id && edge.source.startsWith('ext_') + ); + + // Also check if source_extractor_id is set + const hasDirectSource = obj.source_extractor_id && + spec.extractors.some(e => e.id === obj.source_extractor_id); + + if (!hasSource && !hasDirectSource) { + return { + code: 'OBJECTIVE_NO_SOURCE', + severity: 'error', + path: `objectives.${obj.id}`, + message: `Objective "${obj.name}" has no connected extractor`, + suggestion: 'Connect an extractor to this objective or set source_extractor_id.', + nodeId: obj.id, + }; + } + } + return null; + }, + }, + + // ---- Constraint Validation ---- + + { + code: 'CONSTRAINT_NO_THRESHOLD', + check: (spec) => { + for (const con of spec.constraints || []) { + if (con.threshold === undefined || con.threshold === null) { + return { + code: 'CONSTRAINT_NO_THRESHOLD', + severity: 'error', + path: `constraints.${con.id}`, + message: `Constraint "${con.name}" has no threshold value`, + suggestion: 'Set a threshold value for the constraint.', + nodeId: con.id, + }; + } + } + return null; + }, + }, + + // ---- Warnings (can proceed but risky) ---- + + { + code: 'HIGH_TRIAL_COUNT', + check: (spec) => { + const maxTrials = spec.optimization.budget?.max_trials || 100; + if (maxTrials > 500) { + return { + code: 'HIGH_TRIAL_COUNT', + severity: 'warning', + path: 'optimization.budget.max_trials', + message: `High trial count (${maxTrials}) may take several hours to complete`, + suggestion: 'Consider starting with fewer trials (50-100) to validate the setup.', + }; + } + return null; + }, + }, + + { + code: 'SINGLE_TRIAL', + check: (spec) => { + const maxTrials = spec.optimization.budget?.max_trials || 100; + if (maxTrials === 1) { + return { + code: 'SINGLE_TRIAL', + severity: 'warning', + path: 'optimization.budget.max_trials', + message: 'Only 1 trial configured - this will just run a single evaluation', + suggestion: 'Increase max_trials to explore the design space.', + }; + } + return null; + }, + }, + + { + code: 'DV_NARROW_BOUNDS', + check: (spec) => { + for (const dv of spec.design_variables) { + if (dv.enabled === false) continue; + const range = dv.bounds.max - dv.bounds.min; + const baseline = dv.baseline || (dv.bounds.min + dv.bounds.max) / 2; + const relativeRange = range / Math.abs(baseline || 1); + + if (relativeRange < 0.01) { // Less than 1% variation + return { + code: 'DV_NARROW_BOUNDS', + severity: 'warning', + path: `design_variables.${dv.id}`, + message: `Design variable "${dv.name}" has very narrow bounds (<1% range)`, + suggestion: 'Consider widening the bounds for more meaningful exploration.', + nodeId: dv.id, + }; + } + } + return null; + }, + }, + + { + code: 'MANY_DESIGN_VARS', + check: (spec) => { + const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false); + if (enabledDVs.length > 10) { + return { + code: 'MANY_DESIGN_VARS', + severity: 'warning', + path: 'design_variables', + message: `${enabledDVs.length} design variables - high-dimensional space may need more trials`, + suggestion: 'Consider enabling neural surrogate acceleration or increasing trial budget.', + }; + } + return null; + }, + }, + + { + code: 'MULTI_OBJECTIVE_NO_WEIGHTS', + check: (spec) => { + if (spec.objectives.length > 1) { + const hasWeights = spec.objectives.every(obj => obj.weight !== undefined && obj.weight !== null); + if (!hasWeights) { + return { + code: 'MULTI_OBJECTIVE_NO_WEIGHTS', + severity: 'warning', + path: 'objectives', + message: 'Multi-objective optimization without explicit weights', + suggestion: 'Consider setting weights to control the trade-off between objectives.', + }; + } + } + return null; + }, + }, +]; + +// ============================================================================ +// Main Validation Function +// ============================================================================ + +export function validateSpec(spec: AtomizerSpec): ValidationData { + const errors: ValidationError[] = []; + const warnings: ValidationError[] = []; + + for (const rule of validationRules) { + const result = rule.check(spec); + if (result) { + if (result.severity === 'error') { + errors.push(result); + } else { + warnings.push(result); + } + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + checkedAt: Date.now(), + }; +} + +// ============================================================================ +// Quick Validation (just checks if can run) +// ============================================================================ + +export function canRunOptimization(spec: AtomizerSpec): { canRun: boolean; reason?: string } { + // Check critical requirements only + if (!spec.model.sim?.path) { + return { canRun: false, reason: 'No simulation file configured' }; + } + + const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false); + if (enabledDVs.length === 0) { + return { canRun: false, reason: 'No design variables defined' }; + } + + if (spec.objectives.length === 0) { + return { canRun: false, reason: 'No objectives defined' }; + } + + if (spec.extractors.length === 0) { + return { canRun: false, reason: 'No extractors defined' }; + } + + // Check for invalid bounds + for (const dv of enabledDVs) { + if (dv.bounds.min >= dv.bounds.max) { + return { canRun: false, reason: `Invalid bounds for "${dv.name}"` }; + } + } + + return { canRun: true }; +} + +// ============================================================================ +// Export validation result type for backward compatibility +// ============================================================================ + +export interface LegacyValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +export function toLegacyValidationResult(data: ValidationData): LegacyValidationResult { + return { + valid: data.valid, + errors: data.errors.map(e => e.message), + warnings: data.warnings.map(w => w.message), + }; +} diff --git a/atomizer-dashboard/frontend/src/pages/CanvasView.tsx b/atomizer-dashboard/frontend/src/pages/CanvasView.tsx index 7189322c..3e880e20 100644 --- a/atomizer-dashboard/frontend/src/pages/CanvasView.tsx +++ b/atomizer-dashboard/frontend/src/pages/CanvasView.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight, Save, RefreshCw, Zap, MessageSquare, X, Folder, SlidersHorizontal, Undo2, Redo2 } from 'lucide-react'; +import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight, Save, RefreshCw, Zap, MessageSquare, X, Folder, SlidersHorizontal, Undo2, Redo2, CheckCircle } from 'lucide-react'; import { AtomizerCanvas } from '../components/canvas/AtomizerCanvas'; import { SpecRenderer } from '../components/canvas/SpecRenderer'; import { NodePalette } from '../components/canvas/palette/NodePalette'; @@ -10,8 +10,10 @@ import { ConfigImporter } from '../components/canvas/panels/ConfigImporter'; import { NodeConfigPanel } from '../components/canvas/panels/NodeConfigPanel'; import { NodeConfigPanelV2 } from '../components/canvas/panels/NodeConfigPanelV2'; import { ChatPanel } from '../components/canvas/panels/ChatPanel'; +import { PanelContainer } from '../components/canvas/panels/PanelContainer'; import { useCanvasStore } from '../hooks/useCanvasStore'; import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId } from '../hooks/useSpecStore'; +import { usePanelStore } from '../hooks/usePanelStore'; import { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo'; import { useStudy } from '../context/StudyContext'; import { useChat } from '../hooks/useChat'; @@ -556,6 +558,9 @@ export function CanvasView() { onImport={handleImport} /> + {/* Floating Panels (Introspection, Validation, Error, Results) */} + {useSpecMode && } + {/* Notification Toast */} {notification && (
void; + closePanel: (panel: PanelName) => void; + togglePanel: (panel: PanelName) => void; + + // Panel data persistence + setIntrospectionData: (data: IntrospectionResult) => void; + clearIntrospectionData: () => void; +} +``` + +### Implementation Tasks + +| Task | File | Description | +|------|------|-------------| +| 1.1 | `usePanelStore.ts` | Create Zustand store for panel state | +| 1.2 | `PanelContainer.tsx` | Create container that renders open panels | +| 1.3 | `IntrospectionPanel.tsx` | Refactor to use store instead of local state | +| 1.4 | `NodeConfigPanelV2.tsx` | Remove local panel state, use store | +| 1.5 | `CanvasView.tsx` | Integrate PanelContainer, remove chat panel logic | +| 1.6 | `SpecRenderer.tsx` | Add panel trigger buttons (introspect, validate) | + +### UI Changes + +**Before:** +``` +[Canvas] [Config Panel OR Chat Panel] + ↑ mutually exclusive +``` + +**After:** +``` +[Canvas] [Right Panel Area] + ├── Config Panel (pinnable) + ├── Chat Panel (collapsible) + └── Floating Panels: + ├── Introspection (draggable, persistent) + ├── Validation Results + └── Trial Details +``` + +### Panel Behaviors + +| Panel | Trigger | Persistence | Position | +|-------|---------|-------------|----------| +| **Config** | Node click | While node selected | Right sidebar | +| **Chat** | Toggle button | Always available | Right sidebar (below config) | +| **Introspection** | "Introspect" button | Until explicitly closed | Floating, draggable | +| **Validation** | "Validate" or pre-run | Until fixed or dismissed | Floating | +| **Results** | Click on result badge | Until dismissed | Floating | + +--- + +## Phase 2: Pre-run Validation (HIGH PRIORITY) + +### Problem +- User can click "Run" with incomplete spec +- No feedback about missing extractors, objectives, or connections +- Optimization fails silently or with cryptic errors + +### Solution: Validation Pipeline + +```typescript +// Types of validation +interface ValidationResult { + valid: boolean; + errors: ValidationError[]; // Must fix before running + warnings: ValidationWarning[]; // Can proceed but risky +} + +interface ValidationError { + code: string; + severity: 'error' | 'warning'; + path: string; // e.g., "objectives[0]" + message: string; + suggestion?: string; + autoFix?: () => void; +} +``` + +### Validation Rules + +| Rule | Severity | Message | +|------|----------|---------| +| No design variables | Error | "Add at least one design variable" | +| No objectives | Error | "Add at least one objective" | +| Objective not connected to extractor | Error | "Objective '{name}' has no source extractor" | +| Extractor type not set | Error | "Extractor '{name}' needs a type selected" | +| Design var bounds invalid | Error | "Min must be less than max for '{name}'" | +| No model file | Error | "No simulation file configured" | +| Custom extractor no code | Warning | "Custom extractor '{name}' has no code" | +| High trial count (>500) | Warning | "Large budget may take hours to complete" | +| Single trial | Warning | "Only 1 trial - results won't be meaningful" | + +### Implementation Tasks + +| Task | File | Description | +|------|------|-------------| +| 2.1 | `validation/specValidator.ts` | Client-side validation rules | +| 2.2 | `ValidationPanel.tsx` | Display validation results | +| 2.3 | `SpecRenderer.tsx` | Add "Validate" button, pre-run check | +| 2.4 | `api/routes/spec.py` | Server-side validation endpoint | +| 2.5 | `useSpecStore.ts` | Add `validate()` action | + +### UI Flow + +``` +User clicks "Run Optimization" + ↓ +[Validate Spec] ──failed──→ [Show ValidationPanel] + ↓ passed │ +[Confirm Dialog] │ + ↓ confirmed │ +[Start Optimization] ←── fix ─────┘ +``` + +--- + +## Phase 3: Error Handling & Recovery (HIGH PRIORITY) + +### Problem +- NX crashes don't show useful feedback +- Solver failures leave user confused +- No way to resume after errors + +### Solution: Error Classification & Display + +```typescript +interface OptimizationError { + type: 'nx_crash' | 'solver_fail' | 'extractor_error' | 'config_error' | 'system_error'; + trial?: number; + message: string; + details?: string; + recoverable: boolean; + suggestions: string[]; +} +``` + +### Error Handling Strategy + +| Error Type | Display | Recovery | +|------------|---------|----------| +| NX Crash | Toast + Error Panel | Retry trial, skip trial | +| Solver Failure | Badge on trial | Mark infeasible, continue | +| Extractor Error | Log + badge | Use NaN, continue | +| Config Error | Block run | Show validation panel | +| System Error | Full modal | Restart optimization | + +### Implementation Tasks + +| Task | File | Description | +|------|------|-------------| +| 3.1 | `ErrorBoundary.tsx` | Wrap canvas in error boundary | +| 3.2 | `ErrorPanel.tsx` | Detailed error display with suggestions | +| 3.3 | `optimization.py` | Enhanced error responses with type/recovery | +| 3.4 | `SpecRenderer.tsx` | Error state handling, retry buttons | +| 3.5 | `useOptimizationStatus.ts` | Hook for status polling with error handling | + +--- + +## Phase 4: Live Updates via WebSocket (MEDIUM PRIORITY) + +### Problem +- Current polling (3s) is inefficient and has latency +- Missed updates between polls +- No real-time progress indication + +### Solution: WebSocket for Trial Updates + +```typescript +// WebSocket events +interface TrialStartEvent { + type: 'trial_start'; + trial_number: number; + params: Record; +} + +interface TrialCompleteEvent { + type: 'trial_complete'; + trial_number: number; + objectives: Record; + is_best: boolean; + is_feasible: boolean; +} + +interface OptimizationCompleteEvent { + type: 'optimization_complete'; + best_trial: number; + total_trials: number; +} +``` + +### Implementation Tasks + +| Task | File | Description | +|------|------|-------------| +| 4.1 | `websocket.py` | Add optimization events to WS | +| 4.2 | `run_optimization.py` | Emit events during optimization | +| 4.3 | `useOptimizationWebSocket.ts` | Hook for WS subscription | +| 4.4 | `SpecRenderer.tsx` | Use WS instead of polling | +| 4.5 | `ResultBadge.tsx` | Animate on new results | + +--- + +## Phase 5: Convergence Visualization (MEDIUM PRIORITY) + +### Problem +- No visual feedback on optimization progress +- Can't tell if converging or stuck +- No Pareto front visualization for multi-objective + +### Solution: Embedded Charts + +### Components + +| Component | Description | +|-----------|-------------| +| `ConvergenceSparkline` | Tiny chart in ObjectiveNode showing trend | +| `ProgressRing` | Circular progress in header (trials/total) | +| `ConvergenceChart` | Full chart in Results panel | +| `ParetoPlot` | 2D Pareto front for multi-objective | + +### Implementation Tasks + +| Task | File | Description | +|------|------|-------------| +| 5.1 | `ConvergenceSparkline.tsx` | SVG sparkline component | +| 5.2 | `ObjectiveNode.tsx` | Integrate sparkline | +| 5.3 | `ProgressRing.tsx` | Circular progress indicator | +| 5.4 | `ConvergenceChart.tsx` | Full chart with Recharts | +| 5.5 | `ResultsPanel.tsx` | Panel showing detailed results | + +--- + +## Phase 6: End-to-End Testing (MEDIUM PRIORITY) + +### Problem +- No automated tests for canvas operations +- Manual testing is time-consuming and error-prone +- Regressions go unnoticed + +### Solution: Playwright E2E Tests + +### Test Scenarios + +| Test | Steps | Assertions | +|------|-------|------------| +| Load study | Navigate to /canvas/{id} | Spec loads, nodes render | +| Add design var | Drag from palette | Node appears, spec updates | +| Connect nodes | Drag edge | Edge renders, spec has edge | +| Edit node | Click node, change value | Value persists, API called | +| Run validation | Click validate | Errors shown for incomplete | +| Start optimization | Complete spec, click run | Status shows running | +| View results | Wait for trial | Badge shows value | +| Stop optimization | Click stop | Status shows stopped | + +### Implementation Tasks + +| Task | File | Description | +|------|------|-------------| +| 6.1 | `e2e/canvas.spec.ts` | Basic canvas operations | +| 6.2 | `e2e/optimization.spec.ts` | Run/stop/status flow | +| 6.3 | `e2e/panels.spec.ts` | Panel open/close/persist | +| 6.4 | `playwright.config.ts` | Configure Playwright | +| 6.5 | `CI workflow` | Run tests in GitHub Actions | + +--- + +## Implementation Order + +``` +Week 1: +├── Phase 1: Panel Management (critical UX fix) +│ ├── Day 1-2: usePanelStore + PanelContainer +│ └── Day 3-4: Refactor existing panels +│ +├── Phase 2: Validation (prevent user errors) +│ └── Day 5: Validation rules + UI + +Week 2: +├── Phase 3: Error Handling +│ ├── Day 1-2: Error types + ErrorPanel +│ └── Day 3: Integration with optimization flow +│ +├── Phase 4: WebSocket Updates +│ └── Day 4-5: WS events + frontend hook + +Week 3: +├── Phase 5: Visualization +│ ├── Day 1-2: Sparklines +│ └── Day 3: Progress indicators +│ +├── Phase 6: Testing +│ └── Day 4-5: Playwright setup + core tests +``` + +--- + +## Quick Wins (Can Do Now) + +These can be implemented immediately with minimal changes: + +1. **Persist introspection data in localStorage** + - Cache introspection results + - Restore on panel reopen + +2. **Add loading states to all buttons** + - Disable during operations + - Show spinners + +3. **Add confirmation dialogs** + - Before stopping optimization + - Before clearing canvas + +4. **Improve error messages** + - Parse NX error logs + - Show actionable suggestions + +--- + +## Files to Create/Modify + +### New Files +``` +atomizer-dashboard/frontend/src/ +├── hooks/ +│ ├── usePanelStore.ts +│ └── useOptimizationWebSocket.ts +├── components/canvas/ +│ ├── PanelContainer.tsx +│ ├── panels/ +│ │ ├── ValidationPanel.tsx +│ │ ├── ErrorPanel.tsx +│ │ └── ResultsPanel.tsx +│ └── visualization/ +│ ├── ConvergenceSparkline.tsx +│ ├── ProgressRing.tsx +│ └── ConvergenceChart.tsx +└── lib/ + └── validation/ + └── specValidator.ts + +e2e/ +├── canvas.spec.ts +├── optimization.spec.ts +└── panels.spec.ts +``` + +### Modified Files +``` +atomizer-dashboard/frontend/src/ +├── pages/CanvasView.tsx +├── components/canvas/SpecRenderer.tsx +├── components/canvas/panels/IntrospectionPanel.tsx +├── components/canvas/panels/NodeConfigPanelV2.tsx +├── components/canvas/nodes/ObjectiveNode.tsx +└── hooks/useSpecStore.ts + +atomizer-dashboard/backend/api/ +├── routes/optimization.py +├── routes/spec.py +└── websocket.py +``` + +--- + +## Success Criteria + +| Phase | Success Metric | +|-------|----------------| +| 1 | Introspection panel persists across node selections | +| 2 | Invalid spec shows clear error before run | +| 3 | NX errors display with recovery options | +| 4 | Results update within 500ms of trial completion | +| 5 | Convergence trend visible on objective nodes | +| 6 | All E2E tests pass in CI | + +--- + +## Next Steps + +1. Review this plan +2. Start with Phase 1 (Panel Management) - fixes your immediate issue +3. Implement incrementally, commit after each phase