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:
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
375
atomizer-dashboard/frontend/src/hooks/usePanelStore.ts
Normal file
375
atomizer-dashboard/frontend/src/hooks/usePanelStore.ts
Normal 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,
|
||||
}));
|
||||
394
atomizer-dashboard/frontend/src/lib/validation/specValidator.ts
Normal file
394
atomizer-dashboard/frontend/src/lib/validation/specValidator.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
438
docs/plans/CANVAS_ROBUSTNESS_PLAN.md
Normal file
438
docs/plans/CANVAS_ROBUSTNESS_PLAN.md
Normal 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
|
||||
Reference in New Issue
Block a user