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

@@ -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:

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>

View File

@@ -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 (
<div
className="bg-dark-850 border border-red-500/50 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
onClick={() => minimizePanel('error')}
>
<AlertOctagon size={16} className="text-red-400" />
<span className="text-sm text-white font-medium">
{panel.errors.length} Error{panel.errors.length !== 1 ? 's' : ''}
</span>
<Maximize2 size={14} className="text-dark-400" />
</div>
);
}
return (
<div className="bg-dark-850 border border-red-500/30 rounded-xl w-[420px] max-h-[500px] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700 bg-red-500/5">
<div className="flex items-center gap-2">
<AlertOctagon size={18} className="text-red-400" />
<span className="font-medium text-white">
Optimization Errors ({panel.errors.length})
</span>
</div>
<div className="flex items-center gap-1">
{panel.errors.length > 1 && (
<button
onClick={clearErrors}
className="p-1.5 text-dark-400 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
title="Clear all errors"
>
<Trash2 size={14} />
</button>
)}
<button
onClick={() => minimizePanel('error')}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Minimize"
>
<Minimize2 size={14} />
</button>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<X size={14} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{sortedErrors.map((error) => (
<ErrorItem
key={error.timestamp}
error={error}
onDismiss={() => dismissError(error.timestamp)}
onRetry={onRetry}
onSkipTrial={onSkipTrial}
/>
))}
</div>
</div>
);
}
// ============================================================================
// 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 (
<div className="bg-dark-800 rounded-lg border border-dark-700 overflow-hidden">
{/* Error header */}
<div className="flex items-start gap-3 p-3">
<div className="p-2 bg-red-500/10 rounded-lg flex-shrink-0">
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-red-400 uppercase tracking-wide">
{typeLabel}
</span>
{error.trial !== undefined && (
<span className="text-xs text-dark-500">
Trial #{error.trial}
</span>
)}
<span className="text-xs text-dark-600 ml-auto">
{timeAgo}
</span>
</div>
<p className="text-sm text-white">{error.message}</p>
{error.details && (
<p className="text-xs text-dark-400 mt-1 font-mono bg-dark-900 p-2 rounded mt-2 max-h-20 overflow-y-auto">
{error.details}
</p>
)}
</div>
<button
onClick={onDismiss}
className="p-1 text-dark-500 hover:text-white hover:bg-dark-700 rounded transition-colors flex-shrink-0"
title="Dismiss"
>
<X size={14} />
</button>
</div>
{/* Suggestions */}
{error.suggestions.length > 0 && (
<div className="px-3 pb-3">
<p className="text-xs text-dark-500 mb-1.5">Suggestions:</p>
<ul className="text-xs text-dark-300 space-y-1">
{error.suggestions.map((suggestion, idx) => (
<li key={idx} className="flex items-start gap-1.5">
<span className="text-dark-500">-</span>
<span>{suggestion}</span>
</li>
))}
</ul>
</div>
)}
{/* Actions */}
{error.recoverable && (
<div className="flex items-center gap-2 px-3 pb-3">
{onRetry && (
<button
onClick={() => onRetry(error.trial)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 hover:bg-primary-500
text-white text-xs font-medium rounded transition-colors"
>
<RefreshCw size={12} />
Retry{error.trial !== undefined ? ' Trial' : ''}
</button>
)}
{onSkipTrial && error.trial !== undefined && (
<button
onClick={() => onSkipTrial(error.trial!)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600
text-dark-200 text-xs font-medium rounded transition-colors"
>
Skip Trial
</button>
)}
</div>
)}
</div>
);
}
// ============================================================================
// Helper Functions
// ============================================================================
function getErrorIcon(type: OptimizationError['type']) {
switch (type) {
case 'nx_crash':
return <Cpu size={16} className="text-red-400" />;
case 'solver_fail':
return <AlertTriangle size={16} className="text-amber-400" />;
case 'extractor_error':
return <FileWarning size={16} className="text-orange-400" />;
case 'config_error':
return <Settings size={16} className="text-blue-400" />;
case 'system_error':
return <Server size={16} className="text-purple-400" />;
default:
return <Bug size={16} className="text-red-400" />;
}
}
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;

View File

@@ -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<Set<string>>(
new Set(['expressions', 'extractors', 'file_deps', 'fea_results', 'fem_mesh', 'sim_solutions', 'sim_bcs'])
);
const [searchTerm, setSearchTerm] = useState('');
const [modelFiles, setModelFiles] = useState<ModelFilesResponse | null>(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<HTMLSelectElement>) => {
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 (
<div
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
onClick={() => minimizePanel('introspection')}
>
<Search size={16} className="text-primary-400" />
<span className="text-sm text-white font-medium">
Model Introspection
{data?.selectedFile && <span className="text-dark-400 ml-1">({data.selectedFile})</span>}
</span>
<Maximize2 size={14} className="text-dark-400" />
</div>
);
}
return (
<div className="bg-dark-850 border border-dark-700 rounded-xl w-80 max-h-[70vh] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<div className="flex items-center gap-2">
<Search size={16} className="text-primary-400" />
<span className="font-medium text-white text-sm">
Model Introspection
{data?.selectedFile && <span className="text-primary-400 ml-1">({data.selectedFile})</span>}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => runIntrospection(data?.selectedFile)}
disabled={isLoading}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Refresh"
>
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
</button>
<button
onClick={() => minimizePanel('introspection')}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Minimize"
>
<Minimize2 size={14} />
</button>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<X size={14} />
</button>
</div>
</div>
{/* File Selector + Search */}
<div className="px-4 py-2 border-b border-dark-700 space-y-2">
{data?.studyId && modelFiles && modelFiles.all_files.length > 0 && (
<div className="flex items-center gap-2">
<label className="text-xs text-dark-400 whitespace-nowrap">File:</label>
<select
value={data?.selectedFile || ''}
onChange={handleFileChange}
disabled={isLoading || isLoadingFiles}
className="flex-1 px-2 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
text-sm text-white focus:outline-none focus:border-primary-500
disabled:opacity-50"
>
<option value="">Default (Assembly)</option>
{modelFiles.files.sim.length > 0 && (
<optgroup label="Simulation (.sim)">
{modelFiles.files.sim.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
{modelFiles.files.afm.length > 0 && (
<optgroup label="Assembly FEM (.afm)">
{modelFiles.files.afm.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
{modelFiles.files.fem.length > 0 && (
<optgroup label="FEM (.fem)">
{modelFiles.files.fem.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
{modelFiles.files.prt.length > 0 && (
<optgroup label="Geometry (.prt)">
{modelFiles.files.prt.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
{modelFiles.files.idealized.length > 0 && (
<optgroup label="Idealized (_i.prt)">
{modelFiles.files.idealized.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
</select>
{isLoadingFiles && (
<RefreshCw size={12} className="animate-spin text-dark-400" />
)}
</div>
)}
<input
type="text"
placeholder="Filter expressions..."
value={searchTerm}
onChange={(e) => 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"
/>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center h-32 text-dark-500">
<RefreshCw size={20} className="animate-spin mr-2" />
Analyzing model...
</div>
) : error ? (
<div className="p-4 text-red-400 text-sm">{error}</div>
) : result ? (
<div className="p-2 space-y-2">
{/* Solver Type */}
{result.solver_type && (
<div className="p-2 bg-dark-800 rounded-lg">
<div className="flex items-center gap-2 text-sm">
<Cpu size={14} className="text-violet-400" />
<span className="text-dark-300">Solver:</span>
<span className="text-white font-medium">{result.solver_type as string}</span>
</div>
</div>
)}
{/* Expressions Section */}
<div className="border border-dark-700 rounded-lg overflow-hidden">
<button
onClick={() => toggleSection('expressions')}
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-2">
<SlidersHorizontal size={14} className="text-emerald-400" />
<span className="text-sm font-medium text-white">
Expressions ({filteredExpressions.length})
</span>
</div>
{expandedSections.has('expressions') ? (
<ChevronDown size={14} className="text-dark-400" />
) : (
<ChevronRight size={14} className="text-dark-400" />
)}
</button>
{expandedSections.has('expressions') && (
<div className="p-2 space-y-1 max-h-48 overflow-y-auto">
{filteredExpressions.length === 0 ? (
<p className="text-xs text-dark-500 text-center py-2">
No expressions found
</p>
) : (
filteredExpressions.map((expr) => (
<div
key={expr.name}
className="flex items-center justify-between p-2 bg-dark-850 rounded hover:bg-dark-750 group transition-colors"
>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{expr.name}</p>
<p className="text-xs text-dark-500">
{expr.value} {expr.units || expr.unit || ''}
{expr.source === 'inferred' && (
<span className="ml-1 text-amber-500">(inferred)</span>
)}
</p>
</div>
<button
onClick={() => addExpressionAsDesignVar(expr)}
className="p-1.5 text-dark-500 hover:text-primary-400 hover:bg-dark-700 rounded
opacity-0 group-hover:opacity-100 transition-all"
title="Add as Design Variable"
>
<Plus size={14} />
</button>
</div>
))
)}
</div>
)}
</div>
{/* Mass Properties Section */}
{result.mass_properties && (
<div className="border border-dark-700 rounded-lg overflow-hidden">
<button
onClick={() => toggleSection('mass')}
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-2">
<Scale size={14} className="text-blue-400" />
<span className="text-sm font-medium text-white">Mass Properties</span>
</div>
{expandedSections.has('mass') ? (
<ChevronDown size={14} className="text-dark-400" />
) : (
<ChevronRight size={14} className="text-dark-400" />
)}
</button>
{expandedSections.has('mass') && (
<div className="p-2 space-y-1">
{(result.mass_properties as Record<string, unknown>).mass_kg !== undefined && (
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
<span className="text-dark-400">Mass</span>
<span className="text-white font-mono">
{((result.mass_properties as Record<string, unknown>).mass_kg as number).toFixed(4)} kg
</span>
</div>
)}
</div>
)}
</div>
)}
{/* More sections can be added here following the same pattern as the original IntrospectionPanel */}
</div>
) : (
<div className="p-4 text-center text-dark-500 text-sm">
Click refresh to analyze the model
</div>
)}
</div>
</div>
);
}
export default FloatingIntrospectionPanel;

View File

@@ -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 && (
<button
onClick={() => setShowIntrospection(true)}
onClick={handleOpenIntrospection}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-primary-500/20
hover:bg-primary-500/30 border border-primary-500/30 rounded-lg
text-primary-400 text-sm font-medium transition-colors"
@@ -309,16 +317,8 @@ function ModelNodeConfig({ spec }: SpecConfigProps) {
Introspect Model
</button>
)}
{showIntrospection && spec.model.sim?.path && (
<div className="fixed top-20 right-96 z-40">
<IntrospectionPanel
filePath={spec.model.sim.path}
studyId={useSpecStore.getState().studyId || undefined}
onClose={() => setShowIntrospection(false)}
/>
</div>
)}
{/* Note: IntrospectionPanel is now rendered by PanelContainer, not here */}
</>
);
}

View File

@@ -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<PanelName | null>(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 (
<div
key={panel}
className="fixed select-none"
style={{
left: position.x,
top: position.y,
zIndex: getZIndex(panel),
cursor: dragging?.panel === panel ? 'grabbing' : 'default',
}}
onClick={() => handlePanelClick(panel)}
>
{/* Drag handle - the header area */}
<div
className="absolute top-0 left-0 right-0 h-12 cursor-grab active:cursor-grabbing"
onMouseDown={(e) => handleDragStart(panel, e, position)}
style={{ zIndex: 1 }}
/>
{/* Panel content */}
<div className="relative" style={{ zIndex: 0 }}>
{children}
</div>
</div>
);
};
// Determine what to render
const panels = (
<>
{/* Introspection Panel */}
{renderDraggable(
'introspection',
introspectionPanel.position || { x: 100, y: 100 },
introspectionPanel.open,
<FloatingIntrospectionPanel onClose={() => closePanel('introspection')} />
)}
{/* Validation Panel */}
{renderDraggable(
'validation',
validationPanel.position || { x: 150, y: 150 },
validationPanel.open,
<FloatingValidationPanel onClose={() => closePanel('validation')} />
)}
{/* Error Panel */}
{renderDraggable(
'error',
errorPanel.position || { x: 200, y: 100 },
errorPanel.open,
<ErrorPanel
onClose={() => closePanel('error')}
onRetry={onRetry}
onSkipTrial={onSkipTrial}
/>
)}
{/* Results Panel */}
{renderDraggable(
'results',
resultsPanel.position || { x: 250, y: 150 },
resultsPanel.open,
<ResultsPanel onClose={() => closePanel('results')} />
)}
</>
);
// Use portal if container specified, otherwise render in place
if (container) {
return createPortal(panels, container);
}
return panels;
}
export default PanelContainer;

View File

@@ -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 (
<div
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
onClick={() => minimizePanel('results')}
>
<Trophy size={16} className={data.isBest ? 'text-amber-400' : 'text-dark-400'} />
<span className="text-sm text-white font-medium">
Trial #{data.trialNumber}
</span>
<Maximize2 size={14} className="text-dark-400" />
</div>
);
}
return (
<div className="bg-dark-850 border border-dark-700 rounded-xl w-80 max-h-[500px] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<div className="flex items-center gap-2">
<Trophy size={18} className={data.isBest ? 'text-amber-400' : 'text-dark-400'} />
<span className="font-medium text-white">
Trial #{data.trialNumber}
</span>
{data.isBest && (
<span className="px-1.5 py-0.5 text-xs bg-amber-500/20 text-amber-400 rounded">
Best
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => minimizePanel('results')}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Minimize"
>
<Minimize2 size={14} />
</button>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<X size={14} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Status */}
<div className="flex items-center gap-3">
{data.isFeasible ? (
<div className="flex items-center gap-1.5 text-green-400">
<CheckCircle size={16} />
<span className="text-sm font-medium">Feasible</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-red-400">
<XCircle size={16} />
<span className="text-sm font-medium">Infeasible</span>
</div>
)}
<div className="flex items-center gap-1.5 text-dark-400 ml-auto">
<Clock size={14} />
<span className="text-xs">{timestamp}</span>
</div>
</div>
{/* Parameters */}
<div>
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
<SlidersHorizontal size={12} />
Parameters
</h4>
<div className="space-y-1">
{Object.entries(data.params).map(([name, value]) => (
<div key={name} className="flex justify-between p-2 bg-dark-800 rounded text-sm">
<span className="text-dark-300">{name}</span>
<span className="text-white font-mono">{formatValue(value)}</span>
</div>
))}
</div>
</div>
{/* Objectives */}
<div>
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
<Target size={12} />
Objectives
</h4>
<div className="space-y-1">
{Object.entries(data.objectives).map(([name, value]) => (
<div key={name} className="flex justify-between p-2 bg-dark-800 rounded text-sm">
<span className="text-dark-300">{name}</span>
<span className="text-primary-400 font-mono">{formatValue(value)}</span>
</div>
))}
</div>
</div>
{/* Constraints (if any) */}
{data.constraints && Object.keys(data.constraints).length > 0 && (
<div>
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
<AlertTriangle size={12} />
Constraints
</h4>
<div className="space-y-1">
{Object.entries(data.constraints).map(([name, constraint]) => (
<div
key={name}
className={`flex justify-between p-2 rounded text-sm ${
constraint.feasible ? 'bg-dark-800' : 'bg-red-500/10 border border-red-500/20'
}`}
>
<span className="text-dark-300 flex items-center gap-1.5">
{constraint.feasible ? (
<CheckCircle size={12} className="text-green-400" />
) : (
<XCircle size={12} className="text-red-400" />
)}
{name}
</span>
<span className={`font-mono ${constraint.feasible ? 'text-white' : 'text-red-400'}`}>
{formatValue(constraint.value)}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
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;

View File

@@ -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 (
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 max-w-md w-full z-10">
{validation.errors.length > 0 && (
@@ -30,3 +61,199 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
</div>
);
}
// ============================================================================
// 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 (
<div
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
onClick={() => minimizePanel('validation')}
>
{valid ? (
<CheckCircle size={16} className="text-green-400" />
) : (
<AlertCircle size={16} className="text-red-400" />
)}
<span className="text-sm text-white font-medium">
Validation {valid ? 'Passed' : `(${errors.length} errors)`}
</span>
<Maximize2 size={14} className="text-dark-400" />
</div>
);
}
return (
<div className="bg-dark-850 border border-dark-700 rounded-xl w-96 max-h-[500px] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<div className="flex items-center gap-2">
{valid ? (
<CheckCircle size={18} className="text-green-400" />
) : (
<AlertCircle size={18} className="text-red-400" />
)}
<span className="font-medium text-white">
{valid ? 'Validation Passed' : 'Validation Issues'}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => minimizePanel('validation')}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Minimize"
>
<Minimize2 size={14} />
</button>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<X size={14} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{valid && errors.length === 0 && warnings.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CheckCircle size={40} className="text-green-400 mb-3" />
<p className="text-white font-medium">All checks passed!</p>
<p className="text-sm text-dark-400 mt-1">
Your spec is ready to run.
</p>
</div>
) : (
<>
{/* Errors */}
{errors.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1">
<AlertCircle size={12} />
Errors ({errors.length})
</h4>
{errors.map((error, idx) => (
<ValidationItem
key={`error-${idx}`}
item={error}
severity="error"
onNavigate={() => handleNavigateToNode(error.nodeId)}
/>
))}
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="space-y-2 mt-4">
<h4 className="text-xs font-medium text-amber-400 uppercase tracking-wide flex items-center gap-1">
<AlertTriangle size={12} />
Warnings ({warnings.length})
</h4>
{warnings.map((warning, idx) => (
<ValidationItem
key={`warning-${idx}`}
item={warning}
severity="warning"
onNavigate={() => handleNavigateToNode(warning.nodeId)}
/>
))}
</div>
)}
</>
)}
</div>
{/* Footer */}
{!valid && (
<div className="px-4 py-3 border-t border-dark-700 bg-dark-800/50">
<p className="text-xs text-dark-400">
Fix all errors before running the optimization.
Warnings can be ignored but may cause issues.
</p>
</div>
)}
</div>
);
}
// ============================================================================
// 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 (
<div
className={`p-3 rounded-lg border ${bgColor} ${borderColor} group cursor-pointer hover:bg-opacity-20 transition-colors`}
onClick={onNavigate}
>
<div className="flex items-start gap-2">
{isError ? (
<AlertCircle size={16} className={`${iconColor} flex-shrink-0 mt-0.5`} />
) : (
<AlertTriangle size={16} className={`${iconColor} flex-shrink-0 mt-0.5`} />
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-white">{item.message}</p>
{item.path && (
<p className="text-xs text-dark-400 mt-1 font-mono">{item.path}</p>
)}
{item.suggestion && (
<p className="text-xs text-dark-300 mt-2 italic">{item.suggestion}</p>
)}
</div>
{item.nodeId && (
<ChevronRight
size={16}
className="text-dark-500 group-hover:text-white transition-colors flex-shrink-0"
/>
)}
</div>
</div>
);
}
export default ValidationPanel;

View File

@@ -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<string, unknown>;
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<string, number>;
objectives: Record<string, number>;
constraints?: Record<string, { value: number; feasible: boolean }>;
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<string, unknown>) => 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<PanelStore>()(
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,
}));

View File

@@ -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),
};
}

View File

@@ -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 && <PanelContainer />}
{/* Notification Toast */}
{notification && (
<div

View File

@@ -0,0 +1,438 @@
# Canvas Builder Robustness & Enhancement Plan
**Created**: January 21, 2026
**Branch**: `feature/studio-enhancement`
**Status**: Planning
---
## Executive Summary
This plan addresses critical issues and enhancements to make the Canvas Builder robust and production-ready:
1. **Panel Management** - Panels (Introspection, Config, Chat) disappear unexpectedly
2. **Pre-run Validation** - No validation before starting optimization
3. **Error Handling** - Poor feedback when things go wrong
4. **Live Updates** - Polling is inefficient; need WebSocket
5. **Visualization** - No convergence charts or progress indicators
6. **Testing** - No automated tests for critical flows
---
## Phase 1: Panel Management System (HIGH PRIORITY)
### Problem
- IntrospectionPanel disappears when user clicks elsewhere on canvas
- Panel state is lost (e.g., introspection results, expanded sections)
- No way to have multiple panels open simultaneously
- Chat panel and Config panel are mutually exclusive
### Root Cause
```typescript
// Current: Local state in ModelNodeConfig (NodeConfigPanelV2.tsx:275)
const [showIntrospection, setShowIntrospection] = useState(false);
// When selectedNodeId changes, ModelNodeConfig unmounts, losing state
```
### Solution: Centralized Panel Store
Create `usePanelStore.ts` - a Zustand store for panel management:
```typescript
// atomizer-dashboard/frontend/src/hooks/usePanelStore.ts
interface PanelState {
// Panel visibility
panels: {
introspection: { open: boolean; filePath?: string; data?: IntrospectionResult };
config: { open: boolean; nodeId?: string };
chat: { open: boolean; powerMode: boolean };
validation: { open: boolean; errors?: ValidationError[] };
results: { open: boolean; trialId?: number };
};
// Actions
openPanel: (panel: PanelName, data?: any) => 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<string, number>;
}
interface TrialCompleteEvent {
type: 'trial_complete';
trial_number: number;
objectives: Record<string, number>;
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