feat: Add Analysis page, run comparison, notifications, and config editor

Dashboard enhancements:
- Add Analysis page with tabs: Overview, Parameters, Pareto, Correlations, Constraints, Surrogate, Runs
- Add PlotlyCorrelationHeatmap for parameter-objective correlation analysis
- Add PlotlyFeasibilityChart for constraint satisfaction visualization
- Add PlotlySurrogateQuality for FEA vs NN prediction comparison
- Add PlotlyRunComparison for comparing optimization runs within a study

Real-time improvements:
- Replace watchdog file-watching with SQLite database polling for better Windows reliability
- Add DatabasePoller class with 2-second polling interval
- Enhanced WebSocket messages: trial_completed, new_best, pareto_update, progress

Desktop notifications:
- Add useNotifications hook using Web Notifications API
- Add NotificationSettings toggle component
- Notify users when new best solutions are found

Config editor:
- Add PUT /studies/{study_id}/config endpoint with auto-backup
- Add ConfigEditor modal with tabs: General, Variables, Objectives, Settings, JSON
- Prevents editing while optimization is running

Enhanced Pareto visualization:
- Add dark mode styling with transparent backgrounds
- Add stats bar showing Pareto, FEA, NN, and infeasible counts
- Add Pareto front connecting line for 2D view
- Add table showing top 10 Pareto-optimal solutions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Antoine
2025-12-05 19:57:20 -05:00
parent 5c660ff270
commit 5fb94fdf01
27 changed files with 5878 additions and 722 deletions

View File

@@ -0,0 +1,757 @@
import { useState, useEffect, lazy, Suspense, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
BarChart3,
TrendingUp,
Grid3X3,
Target,
Filter,
Brain,
RefreshCw,
Download,
Layers,
LucideIcon
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { Card } from '../components/common/Card';
// Lazy load charts
const PlotlyParetoPlot = lazy(() => import('../components/plotly/PlotlyParetoPlot').then(m => ({ default: m.PlotlyParetoPlot })));
const PlotlyParallelCoordinates = lazy(() => import('../components/plotly/PlotlyParallelCoordinates').then(m => ({ default: m.PlotlyParallelCoordinates })));
const PlotlyParameterImportance = lazy(() => import('../components/plotly/PlotlyParameterImportance').then(m => ({ default: m.PlotlyParameterImportance })));
const PlotlyConvergencePlot = lazy(() => import('../components/plotly/PlotlyConvergencePlot').then(m => ({ default: m.PlotlyConvergencePlot })));
const PlotlyCorrelationHeatmap = lazy(() => import('../components/plotly/PlotlyCorrelationHeatmap').then(m => ({ default: m.PlotlyCorrelationHeatmap })));
const PlotlyFeasibilityChart = lazy(() => import('../components/plotly/PlotlyFeasibilityChart').then(m => ({ default: m.PlotlyFeasibilityChart })));
const PlotlySurrogateQuality = lazy(() => import('../components/plotly/PlotlySurrogateQuality').then(m => ({ default: m.PlotlySurrogateQuality })));
const PlotlyRunComparison = lazy(() => import('../components/plotly/PlotlyRunComparison').then(m => ({ default: m.PlotlyRunComparison })));
const ChartLoading = () => (
<div className="flex items-center justify-center h-64 text-dark-400">
<div className="animate-pulse">Loading chart...</div>
</div>
);
type AnalysisTab = 'overview' | 'parameters' | 'pareto' | 'correlations' | 'constraints' | 'surrogate' | 'runs';
interface RunData {
run_id: number;
name: string;
source: 'FEA' | 'NN';
trial_count: number;
best_value: number | null;
avg_value: number | null;
first_trial: string | null;
last_trial: string | null;
}
interface TrialData {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
constraint_satisfied?: boolean;
source?: 'FEA' | 'NN' | 'V10_FEA';
}
interface ObjectiveData {
name: string;
direction: 'minimize' | 'maximize';
}
interface StudyMetadata {
objectives?: ObjectiveData[];
design_variables?: Array<{ name: string; min?: number; max?: number }>;
sampler?: string;
description?: string;
}
export default function Analysis() {
const navigate = useNavigate();
const { selectedStudy, isInitialized } = useStudy();
const [activeTab, setActiveTab] = useState<AnalysisTab>('overview');
const [loading, setLoading] = useState(true);
const [trials, setTrials] = useState<TrialData[]>([]);
const [metadata, setMetadata] = useState<StudyMetadata | null>(null);
const [paretoFront, setParetoFront] = useState<any[]>([]);
const [runs, setRuns] = useState<RunData[]>([]);
// Redirect if no study selected
useEffect(() => {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate, isInitialized]);
// Load study data
useEffect(() => {
if (!selectedStudy) return;
const loadData = async () => {
setLoading(true);
try {
// Load trial history
const historyRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/history?limit=500`);
const historyData = await historyRes.json();
const trialsData = historyData.trials.map((t: any) => {
let values: number[] = [];
if (t.objectives && Array.isArray(t.objectives)) {
values = t.objectives;
} else if (t.objective !== null && t.objective !== undefined) {
values = [t.objective];
}
const rawSource = t.source || t.user_attrs?.source || 'FEA';
const source: 'FEA' | 'NN' | 'V10_FEA' = rawSource === 'NN' ? 'NN' : rawSource === 'V10_FEA' ? 'V10_FEA' : 'FEA';
return {
trial_number: t.trial_number,
values,
params: t.design_variables || {},
user_attrs: t.user_attrs || {},
constraint_satisfied: t.constraint_satisfied !== false,
source
};
});
setTrials(trialsData);
// Load metadata
const metadataRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/metadata`);
const metadataData = await metadataRes.json();
setMetadata(metadataData);
// Load Pareto front
const paretoRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/pareto-front`);
const paretoData = await paretoRes.json();
if (paretoData.is_multi_objective && paretoData.pareto_front) {
setParetoFront(paretoData.pareto_front);
}
// Load runs data for comparison
const runsRes = await fetch(`/api/optimization/studies/${selectedStudy.id}/runs`);
const runsData = await runsRes.json();
if (runsData.runs) {
setRuns(runsData.runs);
}
} catch (err) {
console.error('Failed to load analysis data:', err);
} finally {
setLoading(false);
}
};
loadData();
}, [selectedStudy]);
// Calculate statistics
const stats = useMemo(() => {
if (trials.length === 0) return null;
const objectives = trials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
if (objectives.length === 0) return null;
const sorted = [...objectives].sort((a, b) => a - b);
const min = sorted[0];
const max = sorted[sorted.length - 1];
const mean = objectives.reduce((a, b) => a + b, 0) / objectives.length;
const median = sorted[Math.floor(sorted.length / 2)];
const stdDev = Math.sqrt(objectives.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / objectives.length);
const p25 = sorted[Math.floor(sorted.length * 0.25)];
const p75 = sorted[Math.floor(sorted.length * 0.75)];
const p90 = sorted[Math.floor(sorted.length * 0.90)];
const feaTrials = trials.filter(t => t.source === 'FEA').length;
const nnTrials = trials.filter(t => t.source === 'NN').length;
const feasible = trials.filter(t => t.constraint_satisfied).length;
return {
min,
max,
mean,
median,
stdDev,
p25,
p75,
p90,
feaTrials,
nnTrials,
feasible,
total: trials.length,
feasibilityRate: (feasible / trials.length) * 100
};
}, [trials]);
// Tabs configuration
const tabs: { id: AnalysisTab; label: string; icon: LucideIcon; disabled?: boolean }[] = [
{ id: 'overview', label: 'Overview', icon: BarChart3 },
{ id: 'parameters', label: 'Parameters', icon: TrendingUp },
{ id: 'pareto', label: 'Pareto', icon: Target, disabled: (metadata?.objectives?.length || 0) <= 1 },
{ id: 'correlations', label: 'Correlations', icon: Grid3X3 },
{ id: 'constraints', label: 'Constraints', icon: Filter },
{ id: 'surrogate', label: 'Surrogate', icon: Brain, disabled: trials.filter(t => t.source === 'NN').length === 0 },
{ id: 'runs', label: 'Runs', icon: Layers, disabled: runs.length <= 1 },
];
// Export data
const handleExportCSV = () => {
if (trials.length === 0) return;
const paramNames = Object.keys(trials[0].params);
const headers = ['trial', 'objective', ...paramNames, 'source', 'feasible'].join(',');
const rows = trials.map(t => [
t.trial_number,
t.values[0],
...paramNames.map(p => t.params[p]),
t.source,
t.constraint_satisfied
].join(','));
const csv = [headers, ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedStudy?.id}_analysis.csv`;
a.click();
URL.revokeObjectURL(url);
};
if (!isInitialized || !selectedStudy) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading...</p>
</div>
</div>
);
}
const isMultiObjective = (metadata?.objectives?.length || 0) > 1;
return (
<div className="w-full max-w-[2400px] mx-auto px-4">
{/* Header */}
<header className="mb-6 flex items-center justify-between border-b border-dark-600 pb-4">
<div>
<h1 className="text-2xl font-bold text-primary-400">Analysis</h1>
<p className="text-dark-400 text-sm">Deep analysis for {selectedStudy.name || selectedStudy.id}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleExportCSV}
className="flex items-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors"
disabled={trials.length === 0}
>
<Download className="w-4 h-4" />
Export CSV
</button>
</div>
</header>
{/* Tab Navigation */}
<div className="flex gap-1 mb-6 border-b border-dark-600 overflow-x-auto">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => !tab.disabled && setActiveTab(tab.id)}
disabled={tab.disabled}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === tab.id
? 'text-primary-400 border-b-2 border-primary-400 -mb-[2px]'
: tab.disabled
? 'text-dark-600 cursor-not-allowed'
: 'text-dark-400 hover:text-white'
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="w-8 h-8 animate-spin text-dark-400" />
</div>
) : (
<>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Summary Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Total Trials</div>
<div className="text-2xl font-bold text-white">{stats.total}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Best Value</div>
<div className="text-2xl font-bold text-green-400">{stats.min.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Mean</div>
<div className="text-2xl font-bold text-white">{stats.mean.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Median</div>
<div className="text-2xl font-bold text-white">{stats.median.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Std Dev</div>
<div className="text-2xl font-bold text-white">{stats.stdDev.toExponential(3)}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Feasibility</div>
<div className="text-2xl font-bold text-primary-400">{stats.feasibilityRate.toFixed(1)}%</div>
</Card>
</div>
)}
{/* Percentile Distribution */}
{stats && (
<Card title="Objective Distribution">
<div className="grid grid-cols-4 gap-4 mb-4">
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">Min</div>
<div className="text-lg font-mono text-green-400">{stats.min.toExponential(3)}</div>
</div>
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">25th %</div>
<div className="text-lg font-mono text-white">{stats.p25.toExponential(3)}</div>
</div>
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">75th %</div>
<div className="text-lg font-mono text-white">{stats.p75.toExponential(3)}</div>
</div>
<div className="text-center p-3 bg-dark-750 rounded-lg">
<div className="text-xs text-dark-400 mb-1">90th %</div>
<div className="text-lg font-mono text-white">{stats.p90.toExponential(3)}</div>
</div>
</div>
</Card>
)}
{/* Convergence Plot */}
{trials.length > 0 && (
<Card title="Convergence Plot">
<Suspense fallback={<ChartLoading />}>
<PlotlyConvergencePlot
trials={trials}
objectiveIndex={0}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
direction="minimize"
height={350}
/>
</Suspense>
</Card>
)}
{/* Best Trials Table */}
<Card title="Top 10 Best Trials">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Rank</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Objective</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
{Object.keys(trials[0]?.params || {}).slice(0, 3).map(p => (
<th key={p} className="text-left py-2 px-3 text-dark-400 font-medium">{p}</th>
))}
</tr>
</thead>
<tbody>
{[...trials]
.sort((a, b) => (a.values[0] ?? Infinity) - (b.values[0] ?? Infinity))
.slice(0, 10)
.map((trial, idx) => (
<tr key={trial.trial_number} className="border-b border-dark-700">
<td className="py-2 px-3">
<span className={`inline-flex w-6 h-6 items-center justify-center rounded-full text-xs font-bold ${
idx === 0 ? 'bg-yellow-500/20 text-yellow-400' :
idx === 1 ? 'bg-gray-400/20 text-gray-300' :
idx === 2 ? 'bg-orange-700/20 text-orange-400' :
'bg-dark-600 text-dark-400'
}`}>
{idx + 1}
</span>
</td>
<td className="py-2 px-3 font-mono text-white">#{trial.trial_number}</td>
<td className="py-2 px-3 font-mono text-green-400">{trial.values[0]?.toExponential(4)}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
trial.source === 'NN' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
}`}>
{trial.source}
</span>
</td>
{Object.keys(trials[0]?.params || {}).slice(0, 3).map(p => (
<td key={p} className="py-2 px-3 font-mono text-dark-300">
{trial.params[p]?.toFixed(4)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* Parameters Tab */}
{activeTab === 'parameters' && (
<div className="space-y-6">
{/* Parameter Importance */}
{trials.length > 0 && metadata?.design_variables && (
<Card title="Parameter Importance">
<Suspense fallback={<ChartLoading />}>
<PlotlyParameterImportance
trials={trials}
designVariables={metadata.design_variables}
objectiveIndex={0}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
height={400}
/>
</Suspense>
</Card>
)}
{/* Parallel Coordinates */}
{trials.length > 0 && metadata && (
<Card title="Parallel Coordinates">
<Suspense fallback={<ChartLoading />}>
<PlotlyParallelCoordinates
trials={trials}
objectives={metadata.objectives || []}
designVariables={metadata.design_variables || []}
paretoFront={paretoFront}
height={450}
/>
</Suspense>
</Card>
)}
</div>
)}
{/* Pareto Tab */}
{activeTab === 'pareto' && isMultiObjective && (
<div className="space-y-6">
{/* Pareto Metrics */}
<div className="grid grid-cols-3 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Pareto Solutions</div>
<div className="text-2xl font-bold text-primary-400">{paretoFront.length}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Objectives</div>
<div className="text-2xl font-bold text-white">{metadata?.objectives?.length || 0}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Dominated Ratio</div>
<div className="text-2xl font-bold text-white">
{trials.length > 0 ? ((1 - paretoFront.length / trials.length) * 100).toFixed(1) : 0}%
</div>
</Card>
</div>
{/* Pareto Front Plot */}
{paretoFront.length > 0 && (
<Card title="Pareto Front">
<Suspense fallback={<ChartLoading />}>
<PlotlyParetoPlot
trials={trials}
paretoFront={paretoFront}
objectives={metadata?.objectives || []}
height={500}
/>
</Suspense>
</Card>
)}
{/* Pareto Solutions Table */}
<Card title="Pareto-Optimal Solutions">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
{metadata?.objectives?.map(obj => (
<th key={obj.name} className="text-left py-2 px-3 text-dark-400 font-medium">{obj.name}</th>
))}
</tr>
</thead>
<tbody>
{paretoFront.slice(0, 20).map((sol, idx) => (
<tr key={idx} className="border-b border-dark-700">
<td className="py-2 px-3 font-mono text-white">#{sol.trial_number}</td>
{sol.values?.map((v: number, i: number) => (
<td key={i} className="py-2 px-3 font-mono text-primary-400">{v?.toExponential(4)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* Correlations Tab */}
{activeTab === 'correlations' && (
<div className="space-y-6">
{/* Correlation Heatmap */}
{trials.length > 2 && (
<Card title="Parameter-Objective Correlation Matrix">
<Suspense fallback={<ChartLoading />}>
<PlotlyCorrelationHeatmap
trials={trials}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
height={Math.min(500, 100 + Object.keys(trials[0]?.params || {}).length * 40)}
/>
</Suspense>
</Card>
)}
{/* Correlation Interpretation Guide */}
<Card title="Interpreting Correlations">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div className="p-3 bg-blue-500/10 rounded-lg border border-blue-500/30">
<div className="text-blue-400 font-semibold mb-1">Strong Positive (0.7 to 1.0)</div>
<p className="text-dark-400 text-xs">Increasing parameter increases objective</p>
</div>
<div className="p-3 bg-blue-500/5 rounded-lg border border-blue-500/20">
<div className="text-blue-300 font-semibold mb-1">Moderate Positive (0.3 to 0.7)</div>
<p className="text-dark-400 text-xs">Some positive relationship</p>
</div>
<div className="p-3 bg-red-500/5 rounded-lg border border-red-500/20">
<div className="text-red-300 font-semibold mb-1">Moderate Negative (-0.7 to -0.3)</div>
<p className="text-dark-400 text-xs">Some negative relationship</p>
</div>
<div className="p-3 bg-red-500/10 rounded-lg border border-red-500/30">
<div className="text-red-400 font-semibold mb-1">Strong Negative (-1.0 to -0.7)</div>
<p className="text-dark-400 text-xs">Increasing parameter decreases objective</p>
</div>
</div>
</Card>
{/* Top Correlations Table */}
{trials.length > 2 && (
<Card title="Strongest Parameter Correlations with Objective">
<CorrelationTable trials={trials} objectiveName={metadata?.objectives?.[0]?.name || 'Objective'} />
</Card>
)}
</div>
)}
{/* Constraints Tab */}
{activeTab === 'constraints' && stats && (
<div className="space-y-6">
<div className="grid grid-cols-3 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Feasible Trials</div>
<div className="text-2xl font-bold text-green-400">{stats.feasible}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Infeasible Trials</div>
<div className="text-2xl font-bold text-red-400">{stats.total - stats.feasible}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Feasibility Rate</div>
<div className="text-2xl font-bold text-primary-400">{stats.feasibilityRate.toFixed(1)}%</div>
</Card>
</div>
{/* Feasibility Over Time Chart */}
<Card title="Feasibility Rate Over Time">
<Suspense fallback={<ChartLoading />}>
<PlotlyFeasibilityChart trials={trials} height={350} />
</Suspense>
</Card>
{/* Infeasible Trials List */}
{stats.total - stats.feasible > 0 && (
<Card title="Recent Infeasible Trials">
<div className="overflow-x-auto max-h-64">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-dark-800">
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Objective</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
</tr>
</thead>
<tbody>
{trials
.filter(t => !t.constraint_satisfied)
.slice(-20)
.reverse()
.map(trial => (
<tr key={trial.trial_number} className="border-b border-dark-700">
<td className="py-2 px-3 font-mono text-white">#{trial.trial_number}</td>
<td className="py-2 px-3 font-mono text-red-400">{trial.values[0]?.toExponential(4) || 'N/A'}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
trial.source === 'NN' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
}`}>
{trial.source}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
)}
{/* Surrogate Tab */}
{activeTab === 'surrogate' && stats && (
<div className="space-y-6">
<div className="grid grid-cols-4 gap-4">
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">FEA Evaluations</div>
<div className="text-2xl font-bold text-blue-400">{stats.feaTrials}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">NN Predictions</div>
<div className="text-2xl font-bold text-purple-400">{stats.nnTrials}</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">NN Ratio</div>
<div className="text-2xl font-bold text-green-400">
{stats.nnTrials > 0 ? `${((stats.nnTrials / stats.total) * 100).toFixed(0)}%` : '0%'}
</div>
</Card>
<Card className="p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Speedup Factor</div>
<div className="text-2xl font-bold text-primary-400">
{stats.feaTrials > 0 ? `${(stats.total / stats.feaTrials).toFixed(1)}x` : '1.0x'}
</div>
</Card>
</div>
{/* Surrogate Quality Charts */}
<Card title="Surrogate Model Analysis">
<Suspense fallback={<ChartLoading />}>
<PlotlySurrogateQuality trials={trials} height={400} />
</Suspense>
</Card>
</div>
)}
{/* Runs Tab */}
{activeTab === 'runs' && runs.length > 0 && (
<div className="space-y-6">
<Card title="Optimization Runs Comparison">
<p className="text-dark-400 text-sm mb-4">
Compare different optimization runs within this study. Studies with adaptive optimization
may have multiple runs (e.g., initial FEA exploration, NN-accelerated iterations).
</p>
<Suspense fallback={<ChartLoading />}>
<PlotlyRunComparison runs={runs} height={400} />
</Suspense>
</Card>
</div>
)}
</>
)}
</div>
);
}
// Helper component for correlation table
function CorrelationTable({ trials, objectiveName }: { trials: TrialData[]; objectiveName: string }) {
const correlations = useMemo(() => {
if (trials.length < 3) return [];
const paramNames = Object.keys(trials[0].params);
const objectives = trials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
const results: { param: string; correlation: number; absCorr: number }[] = [];
paramNames.forEach(param => {
const paramValues = trials.map(t => t.params[param]).filter(v => v !== undefined && !isNaN(v));
const minLen = Math.min(paramValues.length, objectives.length);
if (minLen < 3) return;
// Calculate Pearson correlation
const x = paramValues.slice(0, minLen);
const y = objectives.slice(0, minLen);
const n = x.length;
const meanX = x.reduce((a, b) => a + b, 0) / n;
const meanY = y.reduce((a, b) => a + b, 0) / n;
let numerator = 0;
let denomX = 0;
let denomY = 0;
for (let i = 0; i < n; i++) {
const dx = x[i] - meanX;
const dy = y[i] - meanY;
numerator += dx * dy;
denomX += dx * dx;
denomY += dy * dy;
}
const denominator = Math.sqrt(denomX) * Math.sqrt(denomY);
const corr = denominator === 0 ? 0 : numerator / denominator;
results.push({ param, correlation: corr, absCorr: Math.abs(corr) });
});
return results.sort((a, b) => b.absCorr - a.absCorr);
}, [trials]);
if (correlations.length === 0) {
return <p className="text-dark-400 text-center py-4">Not enough data for correlation analysis</p>;
}
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Parameter</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Correlation with {objectiveName}</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Strength</th>
</tr>
</thead>
<tbody>
{correlations.slice(0, 10).map(({ param, correlation, absCorr }) => (
<tr key={param} className="border-b border-dark-700">
<td className="py-2 px-3 font-mono text-white">{param}</td>
<td className="py-2 px-3">
<div className="flex items-center gap-2">
<div className="w-24 h-2 bg-dark-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${correlation > 0 ? 'bg-blue-500' : 'bg-red-500'}`}
style={{ width: `${absCorr * 100}%`, marginLeft: correlation < 0 ? 'auto' : 0 }}
/>
</div>
<span className={`font-mono ${
absCorr > 0.7 ? 'text-white font-bold' :
absCorr > 0.3 ? 'text-dark-200' : 'text-dark-400'
}`}>
{correlation > 0 ? '+' : ''}{correlation.toFixed(3)}
</span>
</div>
</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
absCorr > 0.7 ? 'bg-primary-500/20 text-primary-400' :
absCorr > 0.3 ? 'bg-yellow-500/20 text-yellow-400' :
'bg-dark-600 text-dark-400'
}`}>
{absCorr > 0.7 ? 'Strong' : absCorr > 0.3 ? 'Moderate' : 'Weak'}
</span>
</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -1,12 +1,15 @@
import { useState, useEffect, lazy, Suspense } from 'react';
import { useState, useEffect, lazy, Suspense, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Terminal } from 'lucide-react';
import { Terminal, Settings } from 'lucide-react';
import { useOptimizationWebSocket } from '../hooks/useWebSocket';
import { useNotifications, formatOptimizationNotification } from '../hooks/useNotifications';
import { apiClient } from '../api/client';
import { useStudy } from '../context/StudyContext';
import { useClaudeTerminal } from '../context/ClaudeTerminalContext';
import { Card } from '../components/common/Card';
import { ControlPanel } from '../components/dashboard/ControlPanel';
import { NotificationSettings } from '../components/NotificationSettings';
import { ConfigEditor } from '../components/ConfigEditor';
import { ParetoPlot } from '../components/ParetoPlot';
import { ParallelCoordinatesPlot } from '../components/ParallelCoordinatesPlot';
import { ParameterImportanceChart } from '../components/ParameterImportanceChart';
@@ -14,6 +17,7 @@ import { ConvergencePlot } from '../components/ConvergencePlot';
import { StudyReportViewer } from '../components/StudyReportViewer';
import { ConsoleOutput } from '../components/ConsoleOutput';
import { ExpandableChart } from '../components/ExpandableChart';
import { CurrentTrialPanel, OptimizerStatePanel } from '../components/tracker';
import type { Trial } from '../types';
// Lazy load Plotly components for better initial load performance
@@ -31,16 +35,10 @@ const ChartLoading = () => (
export default function Dashboard() {
const navigate = useNavigate();
const { selectedStudy, refreshStudies } = useStudy();
const { selectedStudy, refreshStudies, isInitialized } = useStudy();
const selectedStudyId = selectedStudy?.id || null;
// Redirect to home if no study selected
useEffect(() => {
if (!selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate]);
// All hooks must be declared before any conditional returns
const [allTrials, setAllTrials] = useState<Trial[]>([]);
const [displayedTrials, setDisplayedTrials] = useState<Trial[]>([]);
const [bestValue, setBestValue] = useState<number>(Infinity);
@@ -52,9 +50,9 @@ export default function Dashboard() {
const [trialsPage, setTrialsPage] = useState(0);
const trialsPerPage = 50; // Limit trials per page for performance
// Parameter Space axis selection
const [paramXIndex, setParamXIndex] = useState(0);
const [paramYIndex, setParamYIndex] = useState(1);
// Parameter Space axis selection (reserved for future use)
const [_paramXIndex, _setParamXIndex] = useState(0);
const [_paramYIndex, _setParamYIndex] = useState(1);
// Protocol 13: New state for metadata and Pareto front
const [studyMetadata, setStudyMetadata] = useState<any>(null);
@@ -64,9 +62,27 @@ export default function Dashboard() {
// Chart library toggle: 'recharts' (faster) or 'plotly' (more interactive but slower)
const [chartLibrary, setChartLibrary] = useState<'plotly' | 'recharts'>('recharts');
// Process status for tracker panels
const [isRunning, setIsRunning] = useState(false);
const [lastTrialTime, _setLastTrialTime] = useState<number | undefined>(undefined);
// Config editor modal
const [showConfigEditor, setShowConfigEditor] = useState(false);
// Claude terminal from global context
const { isOpen: claudeTerminalOpen, setIsOpen: setClaudeTerminalOpen, isConnected: claudeConnected } = useClaudeTerminal();
// Desktop notifications
const { showNotification } = useNotifications();
const previousBestRef = useRef<number>(Infinity);
// Redirect to home if no study selected (but only after initialization completes)
useEffect(() => {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate, isInitialized]);
const showAlert = (type: 'success' | 'warning', message: string) => {
const id = alertIdCounter;
setAlertIdCounter(prev => prev + 1);
@@ -84,8 +100,22 @@ export default function Dashboard() {
const trial = msg.data as Trial;
setAllTrials(prev => [...prev, trial]);
if (trial.objective !== null && trial.objective !== undefined && trial.objective < bestValue) {
const improvement = previousBestRef.current !== Infinity
? ((previousBestRef.current - trial.objective) / Math.abs(previousBestRef.current)) * 100
: 0;
setBestValue(trial.objective);
previousBestRef.current = trial.objective;
showAlert('success', `New best: ${trial.objective.toFixed(4)} (Trial #${trial.trial_number})`);
// Desktop notification for new best
showNotification(formatOptimizationNotification({
type: 'new_best',
studyName: selectedStudy?.name || selectedStudyId || 'Study',
message: `Best value: ${trial.objective.toExponential(4)}`,
value: trial.objective,
improvement
}));
}
} else if (msg.type === 'trial_pruned') {
setPrunedCount(prev => prev + 1);
@@ -162,9 +192,31 @@ export default function Dashboard() {
}
})
.catch(err => console.error('Failed to load Pareto front:', err));
// Check process status
apiClient.getProcessStatus(selectedStudyId)
.then(data => {
setIsRunning(data.is_running);
})
.catch(err => console.error('Failed to load process status:', err));
}
}, [selectedStudyId]);
// Poll process status periodically
useEffect(() => {
if (!selectedStudyId) return;
const pollStatus = setInterval(() => {
apiClient.getProcessStatus(selectedStudyId)
.then(data => {
setIsRunning(data.is_running);
})
.catch(() => {});
}, 5000);
return () => clearInterval(pollStatus);
}, [selectedStudyId]);
// Sort trials based on selected sort order
useEffect(() => {
let sorted = [...allTrials];
@@ -223,50 +275,19 @@ export default function Dashboard() {
return () => clearInterval(refreshInterval);
}, [selectedStudyId]);
// Sample data for charts when there are too many trials (performance optimization)
const MAX_CHART_POINTS = 200; // Reduced for better performance
const sampleData = <T,>(data: T[], maxPoints: number): T[] => {
if (data.length <= maxPoints) return data;
const step = Math.ceil(data.length / maxPoints);
return data.filter((_, i) => i % step === 0 || i === data.length - 1);
};
// Show loading state while initializing (restoring study from localStorage)
if (!isInitialized) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading study...</p>
</div>
</div>
);
}
// Prepare chart data with proper null/undefined handling
const allValidTrials = allTrials
.filter(t => t.objective !== null && t.objective !== undefined)
.sort((a, b) => a.trial_number - b.trial_number);
// Calculate best_so_far for each trial
let runningBest = Infinity;
const convergenceDataFull: ConvergenceDataPoint[] = allValidTrials.map(trial => {
if (trial.objective < runningBest) {
runningBest = trial.objective;
}
return {
trial_number: trial.trial_number,
objective: trial.objective,
best_so_far: runningBest,
};
});
// Sample for chart rendering performance
const convergenceData = sampleData(convergenceDataFull, MAX_CHART_POINTS);
const parameterSpaceDataFull: ParameterSpaceDataPoint[] = allTrials
.filter(t => t.objective !== null && t.objective !== undefined && t.design_variables)
.map(trial => {
const params = Object.values(trial.design_variables);
return {
trial_number: trial.trial_number,
x: params[paramXIndex] || 0,
y: params[paramYIndex] || 0,
objective: trial.objective,
isBest: trial.objective === bestValue,
};
});
// Sample for chart rendering performance
const parameterSpaceData = sampleData(parameterSpaceDataFull, MAX_CHART_POINTS);
// Note: Chart data sampling is handled by individual chart components
// Calculate average objective
const validObjectives = allTrials.filter(t => t.objective !== null && t.objective !== undefined).map(t => t.objective);
@@ -350,6 +371,21 @@ export default function Dashboard() {
<p className="text-dark-300 mt-1">Real-time optimization monitoring</p>
</div>
<div className="flex gap-2">
{/* Config Editor Button */}
{selectedStudyId && (
<button
onClick={() => setShowConfigEditor(true)}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs bg-dark-700 text-dark-400 hover:bg-dark-600 hover:text-white transition-colors"
title="Edit study configuration"
>
<Settings className="w-4 h-4" />
<span className="hidden sm:inline">Config</span>
</button>
)}
{/* Notification Toggle */}
<NotificationSettings compact />
{/* Claude Code Terminal Toggle Button */}
<button
onClick={() => setClaudeTerminalOpen(!claudeTerminalOpen)}
@@ -421,6 +457,27 @@ export default function Dashboard() {
<ControlPanel onStatusChange={refreshStudies} horizontal />
</div>
{/* Tracker Panels - Current Trial and Optimizer State */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<CurrentTrialPanel
studyId={selectedStudyId}
totalTrials={selectedStudy?.progress.total || 100}
completedTrials={allTrials.length}
isRunning={isRunning}
lastTrialTime={lastTrialTime}
/>
<OptimizerStatePanel
sampler={studyMetadata?.sampler}
nTrials={selectedStudy?.progress.total || 100}
completedTrials={allTrials.length}
feaTrials={allTrialsRaw.filter(t => t.source === 'FEA').length}
nnTrials={allTrialsRaw.filter(t => t.source === 'NN').length}
objectives={studyMetadata?.objectives || []}
isMultiObjective={(studyMetadata?.objectives?.length || 0) > 1}
paretoSize={paretoFront.length}
/>
</div>
{/* Main Layout: Charts (Claude Terminal is now global/floating) */}
<div className="grid gap-4 grid-cols-1">
{/* Main Content - Charts stacked vertically */}
@@ -795,6 +852,15 @@ export default function Dashboard() {
</div>
</main>
</div>
{/* Config Editor Modal */}
{showConfigEditor && selectedStudyId && (
<ConfigEditor
studyId={selectedStudyId}
onClose={() => setShowConfigEditor(false)}
onSaved={() => refreshStudies()}
/>
)}
</div>
);
}

View File

@@ -1,38 +1,33 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
FolderOpen,
Play,
Pause,
CheckCircle,
Clock,
AlertCircle,
ArrowRight,
RefreshCw,
Zap,
FileText,
ChevronDown,
ChevronUp,
Target,
Activity
Activity,
BarChart3,
TrendingUp,
ArrowRight
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { Study } from '../types';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { apiClient } from '../api/client';
import { MarkdownRenderer } from '../components/MarkdownRenderer';
const Home: React.FC = () => {
const { studies, setSelectedStudy, refreshStudies, isLoading } = useStudy();
const [selectedPreview, setSelectedPreview] = useState<Study | null>(null);
const [readme, setReadme] = useState<string>('');
const [readmeLoading, setReadmeLoading] = useState(false);
const [showAllStudies, setShowAllStudies] = useState(false);
const [sortField, setSortField] = useState<'name' | 'status' | 'trials' | 'bestValue'>('trials');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const navigate = useNavigate();
// Load README when a study is selected for preview
@@ -49,7 +44,7 @@ const Home: React.FC = () => {
try {
const response = await apiClient.getStudyReadme(studyId);
setReadme(response.content || 'No README found for this study.');
} catch (error) {
} catch {
setReadme('No README found for this study.');
} finally {
setReadmeLoading(false);
@@ -61,78 +56,88 @@ const Home: React.FC = () => {
navigate('/dashboard');
};
const handleSort = (field: typeof sortField) => {
if (sortField === field) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDir('desc');
}
};
// Sort studies
const sortedStudies = useMemo(() => {
return [...studies].sort((a, b) => {
let aVal: any, bVal: any;
switch (sortField) {
case 'name':
aVal = (a.name || a.id).toLowerCase();
bVal = (b.name || b.id).toLowerCase();
break;
case 'trials':
aVal = a.progress.current;
bVal = b.progress.current;
break;
case 'bestValue':
aVal = a.best_value ?? Infinity;
bVal = b.best_value ?? Infinity;
break;
case 'status':
const statusOrder = { running: 0, paused: 1, completed: 2, not_started: 3 };
aVal = statusOrder[a.status as keyof typeof statusOrder] ?? 4;
bVal = statusOrder[b.status as keyof typeof statusOrder] ?? 4;
break;
default:
return 0;
}
if (sortDir === 'asc') {
return aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
} else {
return aVal > bVal ? -1 : aVal < bVal ? 1 : 0;
}
});
}, [studies, sortField, sortDir]);
// Aggregate stats
const aggregateStats = useMemo(() => {
const totalStudies = studies.length;
const runningStudies = studies.filter(s => s.status === 'running').length;
const completedStudies = studies.filter(s => s.status === 'completed').length;
const totalTrials = studies.reduce((sum, s) => sum + s.progress.current, 0);
const studiesWithValues = studies.filter(s => s.best_value !== null);
const bestOverall = studiesWithValues.length > 0
? studiesWithValues.reduce((best, curr) =>
(curr.best_value! < best.best_value!) ? curr : best
)
: null;
return { totalStudies, runningStudies, completedStudies, totalTrials, bestOverall };
}, [studies]);
const getStatusIcon = (status: string) => {
switch (status) {
case 'running':
return <Play className="w-3.5 h-3.5" />;
return <Play className="w-4 h-4 text-green-400" />;
case 'paused':
return <Pause className="w-3.5 h-3.5" />;
return <Pause className="w-4 h-4 text-orange-400" />;
case 'completed':
return <CheckCircle className="w-3.5 h-3.5" />;
case 'not_started':
return <Clock className="w-3.5 h-3.5" />;
return <CheckCircle className="w-4 h-4 text-blue-400" />;
default:
return <AlertCircle className="w-3.5 h-3.5" />;
return <Clock className="w-4 h-4 text-dark-400" />;
}
};
const getStatusStyles = (status: string) => {
const getStatusColor = (status: string) => {
switch (status) {
case 'running':
return {
badge: 'bg-green-500/20 text-green-400 border-green-500/30',
card: 'border-green-500/30 hover:border-green-500/50',
glow: 'shadow-green-500/10'
};
case 'paused':
return {
badge: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
card: 'border-orange-500/30 hover:border-orange-500/50',
glow: 'shadow-orange-500/10'
};
case 'completed':
return {
badge: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
card: 'border-blue-500/30 hover:border-blue-500/50',
glow: 'shadow-blue-500/10'
};
case 'not_started':
return {
badge: 'bg-dark-600 text-dark-400 border-dark-500',
card: 'border-dark-600 hover:border-dark-500',
glow: ''
};
default:
return {
badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
card: 'border-yellow-500/30 hover:border-yellow-500/50',
glow: 'shadow-yellow-500/10'
};
case 'running': return 'text-green-400 bg-green-500/10';
case 'paused': return 'text-orange-400 bg-orange-500/10';
case 'completed': return 'text-blue-400 bg-blue-500/10';
default: return 'text-dark-400 bg-dark-600';
}
};
// Study sort options
const [studySort, setStudySort] = useState<'date' | 'running' | 'trials'>('date');
// Sort studies based on selected sort option
const sortedStudies = [...studies].sort((a, b) => {
if (studySort === 'running') {
// Running first, then by date
if (a.status === 'running' && b.status !== 'running') return -1;
if (b.status === 'running' && a.status !== 'running') return 1;
}
if (studySort === 'trials') {
// By trial count (most trials first)
return b.progress.current - a.progress.current;
}
// Default: sort by date (newest first)
const aDate = a.last_modified || a.created_at || '';
const bDate = b.last_modified || b.created_at || '';
return bDate.localeCompare(aDate);
});
const displayedStudies = showAllStudies ? sortedStudies : sortedStudies.slice(0, 6);
return (
<div className="min-h-screen bg-dark-900">
{/* Header */}
@@ -162,352 +167,254 @@ const Home: React.FC = () => {
</header>
<main className="max-w-[1920px] mx-auto px-6 py-8">
{/* Study Selection Section */}
<section className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-primary-400" />
Select a Study
</h2>
<div className="flex items-center gap-4">
{/* Sort Controls */}
<div className="flex items-center gap-2">
<span className="text-sm text-dark-400">Sort:</span>
<div className="flex rounded-lg overflow-hidden border border-dark-600">
<button
onClick={() => setStudySort('date')}
className={`px-3 py-1.5 text-sm transition-colors ${
studySort === 'date'
? 'bg-primary-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
}`}
>
Newest
</button>
<button
onClick={() => setStudySort('running')}
className={`px-3 py-1.5 text-sm transition-colors ${
studySort === 'running'
? 'bg-primary-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
}`}
>
Running
</button>
<button
onClick={() => setStudySort('trials')}
className={`px-3 py-1.5 text-sm transition-colors ${
studySort === 'trials'
? 'bg-primary-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
}`}
>
Most Trials
</button>
</div>
</div>
{studies.length > 6 && (
<button
onClick={() => setShowAllStudies(!showAllStudies)}
className="text-sm text-primary-400 hover:text-primary-300 flex items-center gap-1"
>
{showAllStudies ? (
<>Show Less <ChevronUp className="w-4 h-4" /></>
) : (
<>Show All ({studies.length}) <ChevronDown className="w-4 h-4" /></>
)}
</button>
)}
{/* Aggregate Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-dark-400 text-sm mb-2">
<BarChart3 className="w-4 h-4" />
Total Studies
</div>
<div className="text-3xl font-bold text-white">{aggregateStats.totalStudies}</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading studies...
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-green-400 text-sm mb-2">
<Play className="w-4 h-4" />
Running
</div>
) : studies.length === 0 ? (
<div className="text-center py-12 text-dark-400">
<FolderOpen className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No studies found. Create a new study to get started.</p>
<div className="text-3xl font-bold text-green-400">{aggregateStats.runningStudies}</div>
</div>
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-dark-400 text-sm mb-2">
<Activity className="w-4 h-4" />
Total Trials
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{displayedStudies.map((study) => {
const styles = getStatusStyles(study.status);
const isSelected = selectedPreview?.id === study.id;
<div className="text-3xl font-bold text-white">{aggregateStats.totalTrials.toLocaleString()}</div>
</div>
return (
<div
key={study.id}
onClick={() => setSelectedPreview(study)}
className={`
relative p-4 rounded-xl border cursor-pointer transition-all duration-200
bg-dark-800 hover:bg-dark-750
${styles.card} ${styles.glow}
${isSelected ? 'ring-2 ring-primary-500 border-primary-500' : ''}
`}
>
{/* Status Badge */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0 pr-2">
<h3 className="text-white font-medium truncate">{study.name || study.id}</h3>
<p className="text-dark-500 text-xs truncate mt-0.5">{study.id}</p>
</div>
<span className={`flex items-center gap-1.5 px-2 py-1 text-xs font-medium rounded-full border ${styles.badge}`}>
{getStatusIcon(study.status)}
{study.status}
</span>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm mb-3">
<div className="flex items-center gap-1.5 text-dark-400">
<Activity className="w-3.5 h-3.5" />
<span>{study.progress.current} trials</span>
</div>
{study.best_value !== null && (
<div className="flex items-center gap-1.5 text-primary-400">
<Target className="w-3.5 h-3.5" />
<span>{study.best_value.toFixed(4)}</span>
</div>
)}
</div>
{/* Progress Bar */}
<div className="h-1.5 bg-dark-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
study.status === 'running' ? 'bg-green-500' :
study.status === 'completed' ? 'bg-blue-500' : 'bg-primary-500'
}`}
style={{ width: `${Math.min((study.progress.current / study.progress.total) * 100, 100)}%` }}
/>
</div>
{/* Selected Indicator */}
{isSelected && (
<div className="absolute -bottom-px left-1/2 -translate-x-1/2 w-12 h-1 bg-primary-500 rounded-t-full" />
)}
</div>
);
})}
<div className="bg-dark-800 rounded-xl p-4 border border-dark-600">
<div className="flex items-center gap-2 text-primary-400 text-sm mb-2">
<Target className="w-4 h-4" />
Best Overall
</div>
)}
</section>
{/* Study Documentation Section */}
{selectedPreview && (
<section className="animate-in fade-in slide-in-from-bottom-4 duration-300">
{/* Documentation Header */}
<div className="bg-dark-800 rounded-t-xl border border-dark-600 border-b-0">
<div className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-dark-700 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-primary-400" />
</div>
<div>
<h2 className="text-xl font-semibold text-white">{selectedPreview.name || selectedPreview.id}</h2>
<p className="text-dark-400 text-sm">Study Documentation</p>
</div>
</div>
<button
onClick={() => handleSelectStudy(selectedPreview)}
className="flex items-center gap-2 px-5 py-2.5 bg-primary-600 hover:bg-primary-500
text-white rounded-lg transition-all font-medium shadow-lg shadow-primary-500/20
hover:shadow-primary-500/30"
>
Open Dashboard
<ArrowRight className="w-4 h-4" />
</button>
<div className="text-2xl font-bold text-primary-400">
{aggregateStats.bestOverall?.best_value !== null && aggregateStats.bestOverall?.best_value !== undefined
? aggregateStats.bestOverall.best_value.toExponential(3)
: 'N/A'}
</div>
{aggregateStats.bestOverall && (
<div className="text-xs text-dark-400 mt-1 truncate">
{aggregateStats.bestOverall.name || aggregateStats.bestOverall.id}
</div>
)}
</div>
</div>
{/* Two-column layout: Table + Preview */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Study Table */}
<div className="bg-dark-800 rounded-xl border border-dark-600 overflow-hidden">
<div className="px-6 py-4 border-b border-dark-600 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-primary-400" />
Studies
</h2>
<span className="text-sm text-dark-400">{studies.length} studies</span>
</div>
{/* README Content */}
<div className="bg-dark-850 rounded-b-xl border border-dark-600 border-t-0 overflow-hidden">
{readmeLoading ? (
<div className="flex items-center justify-center py-16 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading documentation...
</div>
) : (
<div className="p-8 overflow-x-auto">
<article className="markdown-body max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[[rehypeKatex, { strict: false, trust: true, output: 'html' }]]}
components={{
// Custom heading styles
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-white mb-6 pb-3 border-b border-dark-600">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-semibold text-white mt-10 mb-4 pb-2 border-b border-dark-700">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold text-white mt-8 mb-3">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-medium text-white mt-6 mb-2">
{children}
</h4>
),
// Paragraphs
p: ({ children }) => (
<p className="text-dark-300 leading-relaxed mb-4">
{children}
</p>
),
// Strong/Bold
strong: ({ children }) => (
<strong className="text-white font-semibold">{children}</strong>
),
// Links
a: ({ href, children }) => (
<a
href={href}
className="text-primary-400 hover:text-primary-300 underline underline-offset-2"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
// Lists
ul: ({ children }) => (
<ul className="list-disc list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-dark-300 leading-relaxed">{children}</li>
),
// Code blocks with syntax highlighting
code: ({ inline, className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
{isLoading ? (
<div className="flex items-center justify-center py-16 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading studies...
</div>
) : studies.length === 0 ? (
<div className="text-center py-16 text-dark-400">
<BarChart3 className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No studies found</p>
<p className="text-sm mt-1 text-dark-500">Create a new study to get started</p>
</div>
) : (
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
<table className="w-full">
<thead className="sticky top-0 bg-dark-750 z-10">
<tr className="border-b border-dark-600">
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('name')}
>
<div className="flex items-center gap-1">
Study Name
{sortField === 'name' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('status')}
>
<div className="flex items-center gap-1">
Status
{sortField === 'status' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('trials')}
>
<div className="flex items-center gap-1">
Progress
{sortField === 'trials' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
<th
className="text-left py-3 px-4 text-dark-400 font-medium cursor-pointer hover:text-white transition-colors"
onClick={() => handleSort('bestValue')}
>
<div className="flex items-center gap-1">
Best
{sortField === 'bestValue' && (
sortDir === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
)}
</div>
</th>
</tr>
</thead>
<tbody>
{sortedStudies.map((study) => {
const completionPercent = study.progress.total > 0
? Math.round((study.progress.current / study.progress.total) * 100)
: 0;
if (!inline && language) {
return (
<div className="my-4 rounded-lg overflow-hidden border border-dark-600">
<div className="bg-dark-700 px-4 py-2 text-xs text-dark-400 font-mono border-b border-dark-600">
{language}
</div>
<SyntaxHighlighter
style={oneDark}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '1rem',
background: '#1a1d23',
fontSize: '0.875rem',
}}
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
);
}
if (!inline) {
return (
<pre className="my-4 p-4 bg-dark-700 rounded-lg border border-dark-600 overflow-x-auto">
<code className="text-primary-400 text-sm font-mono">{children}</code>
</pre>
);
}
return (
<code className="px-1.5 py-0.5 bg-dark-700 text-primary-400 rounded text-sm font-mono">
{children}
</code>
);
},
// Tables
table: ({ children }) => (
<div className="my-6 overflow-x-auto rounded-lg border border-dark-600">
<table className="w-full text-sm">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-dark-700 text-white">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody className="divide-y divide-dark-600">
{children}
</tbody>
),
tr: ({ children }) => (
<tr className="hover:bg-dark-750 transition-colors">
{children}
</tr>
),
th: ({ children }) => (
<th className="px-4 py-3 text-left font-semibold text-white border-b border-dark-600">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-3 text-dark-300">
{children}
return (
<tr
key={study.id}
onClick={() => setSelectedPreview(study)}
className={`border-b border-dark-700 hover:bg-dark-750 transition-colors cursor-pointer ${
selectedPreview?.id === study.id ? 'bg-primary-900/20' : ''
}`}
>
<td className="py-3 px-4">
<div className="flex flex-col">
<span className="text-white font-medium truncate max-w-[200px]">
{study.name || study.id}
</span>
{study.name && (
<span className="text-xs text-dark-500 truncate max-w-[200px]">{study.id}</span>
)}
</div>
</td>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="my-4 pl-4 border-l-4 border-primary-500 bg-dark-750 py-3 pr-4 rounded-r-lg">
{children}
</blockquote>
),
// Horizontal rules
hr: () => (
<hr className="my-8 border-dark-600" />
),
// Images
img: ({ src, alt }) => (
<img
src={src}
alt={alt}
className="my-4 rounded-lg max-w-full h-auto border border-dark-600"
/>
),
}}
>
{readme}
</ReactMarkdown>
</article>
</div>
)}
</div>
</section>
)}
<td className="py-3 px-4">
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(study.status)}`}>
{getStatusIcon(study.status)}
{study.status}
</span>
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-dark-600 rounded-full overflow-hidden max-w-[80px]">
<div
className={`h-full transition-all ${
completionPercent >= 100 ? 'bg-green-500' :
completionPercent >= 50 ? 'bg-primary-500' :
'bg-yellow-500'
}`}
style={{ width: `${Math.min(completionPercent, 100)}%` }}
/>
</div>
<span className="text-dark-400 text-sm font-mono w-16">
{study.progress.current}/{study.progress.total}
</span>
</div>
</td>
<td className="py-3 px-4">
<span className={`font-mono text-sm ${study.best_value !== null ? 'text-primary-400' : 'text-dark-500'}`}>
{study.best_value !== null ? study.best_value.toExponential(3) : 'N/A'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Empty State when no study selected */}
{!selectedPreview && studies.length > 0 && (
<section className="flex items-center justify-center py-16 text-dark-400">
<div className="text-center">
<FileText className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p className="text-lg">Select a study to view its documentation</p>
<p className="text-sm mt-1 text-dark-500">Click on any study card above</p>
</div>
</section>
)}
{/* Study Preview */}
<div className="bg-dark-800 rounded-xl border border-dark-600 overflow-hidden flex flex-col">
{selectedPreview ? (
<>
{/* Preview Header */}
<div className="px-6 py-4 border-b border-dark-600 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-dark-700 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-primary-400" />
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold text-white truncate">
{selectedPreview.name || selectedPreview.id}
</h2>
<p className="text-dark-400 text-sm">Study Documentation</p>
</div>
</div>
<button
onClick={() => handleSelectStudy(selectedPreview)}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-500
text-white rounded-lg transition-all font-medium shadow-lg shadow-primary-500/20
hover:shadow-primary-500/30 whitespace-nowrap"
>
Open
<ArrowRight className="w-4 h-4" />
</button>
</div>
{/* Study Quick Stats */}
<div className="px-6 py-3 border-b border-dark-600 flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
{getStatusIcon(selectedPreview.status)}
<span className="text-dark-300 capitalize">{selectedPreview.status}</span>
</div>
<div className="flex items-center gap-2 text-dark-400">
<Activity className="w-4 h-4" />
<span>{selectedPreview.progress.current} / {selectedPreview.progress.total} trials</span>
</div>
{selectedPreview.best_value !== null && (
<div className="flex items-center gap-2 text-primary-400">
<Target className="w-4 h-4" />
<span>Best: {selectedPreview.best_value.toExponential(4)}</span>
</div>
)}
</div>
{/* README Content */}
<div className="flex-1 overflow-y-auto p-6">
{readmeLoading ? (
<div className="flex items-center justify-center py-16 text-dark-400">
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
Loading documentation...
</div>
) : (
<MarkdownRenderer content={readme} />
)}
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-dark-400">
<div className="text-center">
<FileText className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p className="text-lg">Select a study to preview</p>
<p className="text-sm mt-1 text-dark-500">Click on any row in the table</p>
</div>
</div>
)}
</div>
</div>
</main>
</div>
);

View File

@@ -10,14 +10,45 @@ import {
Loader2,
AlertTriangle,
CheckCircle,
Copy
Copy,
Trophy,
TrendingUp,
FileJson,
FileSpreadsheet,
Settings,
ArrowRight,
ChevronDown,
ChevronUp,
Printer
} from 'lucide-react';
import { apiClient } from '../api/client';
import { useStudy } from '../context/StudyContext';
import ReactMarkdown from 'react-markdown';
import { MarkdownRenderer } from '../components/MarkdownRenderer';
interface BestSolution {
best_trial: {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
user_attrs?: Record<string, any>;
timestamp?: string;
} | null;
first_trial: {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
} | null;
improvements: Record<string, {
initial: number;
final: number;
improvement_pct: number;
absolute_change: number;
}>;
total_trials: number;
}
export default function Results() {
const { selectedStudy } = useStudy();
const { selectedStudy, isInitialized } = useStudy();
const navigate = useNavigate();
const [reportContent, setReportContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -25,21 +56,37 @@ export default function Results() {
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [lastGenerated, setLastGenerated] = useState<string | null>(null);
const [bestSolution, setBestSolution] = useState<BestSolution | null>(null);
const [showAllParams, setShowAllParams] = useState(false);
const [exporting, setExporting] = useState<string | null>(null);
// Redirect if no study selected
// Redirect if no study selected (but only after initialization completes)
useEffect(() => {
if (!selectedStudy) {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate]);
}, [selectedStudy, navigate, isInitialized]);
// Load report when study changes
// Load report and best solution when study changes
useEffect(() => {
if (selectedStudy) {
loadReport();
loadBestSolution();
}
}, [selectedStudy]);
// Show loading state while initializing (must be after all hooks)
if (!isInitialized) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading study...</p>
</div>
</div>
);
}
const loadReport = async () => {
if (!selectedStudy) return;
@@ -52,7 +99,7 @@ export default function Results() {
if (data.generated_at) {
setLastGenerated(data.generated_at);
}
} catch (err: any) {
} catch {
// No report yet - show placeholder
setReportContent(null);
} finally {
@@ -60,6 +107,17 @@ export default function Results() {
}
};
const loadBestSolution = async () => {
if (!selectedStudy) return;
try {
const data = await apiClient.getBestSolution(selectedStudy.id);
setBestSolution(data);
} catch {
setBestSolution(null);
}
};
const handleGenerate = async () => {
if (!selectedStudy) return;
@@ -101,17 +159,148 @@ export default function Results() {
URL.revokeObjectURL(url);
};
const handlePrintPDF = () => {
if (!reportContent || !selectedStudy) return;
// Create a printable version of the report
const printWindow = window.open('', '_blank');
if (!printWindow) {
setError('Pop-up blocked. Please allow pop-ups to print PDF.');
return;
}
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${selectedStudy.name} - Optimization Report</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 40px;
color: #1a1a1a;
line-height: 1.6;
}
h1 { color: #2563eb; border-bottom: 2px solid #2563eb; padding-bottom: 10px; }
h2 { color: #1e40af; margin-top: 30px; }
h3 { color: #3730a3; }
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f3f4f6; font-weight: 600; }
tr:nth-child(even) { background: #f9fafb; }
code { background: #f3f4f6; padding: 2px 6px; border-radius: 4px; font-family: 'Monaco', monospace; }
pre { background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 8px; overflow-x: auto; }
pre code { background: transparent; padding: 0; }
blockquote { border-left: 4px solid #2563eb; margin: 20px 0; padding: 10px 20px; background: #eff6ff; }
.header-info { color: #666; margin-bottom: 30px; }
@media print {
body { padding: 20px; }
pre { white-space: pre-wrap; word-wrap: break-word; }
}
</style>
</head>
<body>
<div class="header-info">
<strong>Study:</strong> ${selectedStudy.name}<br>
<strong>Generated:</strong> ${new Date().toLocaleString()}<br>
<strong>Trials:</strong> ${selectedStudy.progress.current} / ${selectedStudy.progress.total}
</div>
${convertMarkdownToHTML(reportContent)}
</body>
</html>
`);
printWindow.document.close();
// Wait for content to load then print
printWindow.onload = () => {
printWindow.print();
};
};
// Simple markdown to HTML converter for print
const convertMarkdownToHTML = (md: string): string => {
return md
// Headers
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
// Bold and italic
.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
// Code blocks
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Lists
.replace(/^\s*[-*]\s+(.*)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)\n(?!<li>)/g, '</ul>$1\n')
.replace(/(?<!<\/ul>)(<li>)/g, '<ul>$1')
// Blockquotes
.replace(/^>\s*(.*)$/gm, '<blockquote>$1</blockquote>')
// Horizontal rules
.replace(/^---$/gm, '<hr>')
// Paragraphs
.replace(/\n\n/g, '</p><p>')
.replace(/^(.+)$/gm, (match) => {
if (match.startsWith('<')) return match;
return match;
});
};
const handleExport = async (format: 'csv' | 'json' | 'config') => {
if (!selectedStudy) return;
setExporting(format);
try {
const data = await apiClient.exportData(selectedStudy.id, format);
if (data.filename && data.content) {
const blob = new Blob([data.content], { type: data.content_type || 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = data.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else if (format === 'json' && data.trials) {
// Direct JSON response
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedStudy.id}_data.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} catch (err: any) {
setError(err.message || `Failed to export ${format}`);
} finally {
setExporting(null);
}
};
if (!selectedStudy) {
return null;
}
const paramEntries = bestSolution?.best_trial?.design_variables
? Object.entries(bestSolution.best_trial.design_variables)
: [];
const visibleParams = showAllParams ? paramEntries : paramEntries.slice(0, 6);
return (
<div className="h-full flex flex-col">
<div className="h-full flex flex-col max-w-[2400px] mx-auto px-4">
{/* Header */}
<header className="mb-6 flex items-center justify-between">
<header className="mb-6 flex items-center justify-between border-b border-dark-600 pb-4">
<div>
<h1 className="text-2xl font-bold text-white">Optimization Report</h1>
<p className="text-dark-400 mt-1">{selectedStudy.name}</p>
<h1 className="text-2xl font-bold text-primary-400">Results</h1>
<p className="text-dark-400 text-sm">{selectedStudy.name}</p>
</div>
<div className="flex gap-2">
<Button
@@ -153,7 +342,156 @@ export default function Results() {
</div>
)}
{/* Main Content */}
{/* Best Solution Card */}
{bestSolution?.best_trial && (
<Card className="mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
<Trophy className="w-5 h-5 text-yellow-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Best Solution</h2>
<p className="text-sm text-dark-400">Trial #{bestSolution.best_trial.trial_number} of {bestSolution.total_trials}</p>
</div>
{bestSolution.improvements.objective && (
<div className="ml-auto flex items-center gap-2 px-4 py-2 bg-green-900/20 rounded-lg border border-green-800/30">
<TrendingUp className="w-5 h-5 text-green-400" />
<span className="text-green-400 font-bold text-lg">
{bestSolution.improvements.objective.improvement_pct > 0 ? '+' : ''}
{bestSolution.improvements.objective.improvement_pct.toFixed(1)}%
</span>
<span className="text-dark-400 text-sm">improvement</span>
</div>
)}
</div>
{/* Objective Value */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="bg-dark-700 rounded-lg p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Best Objective</div>
<div className="text-2xl font-bold text-primary-400">
{bestSolution.best_trial.objective.toExponential(4)}
</div>
</div>
{bestSolution.first_trial && (
<div className="bg-dark-700 rounded-lg p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Initial Value</div>
<div className="text-2xl font-bold text-dark-300">
{bestSolution.first_trial.objective.toExponential(4)}
</div>
</div>
)}
{bestSolution.improvements.objective && (
<div className="bg-dark-700 rounded-lg p-4">
<div className="text-xs text-dark-400 uppercase mb-1">Absolute Change</div>
<div className="text-2xl font-bold text-green-400 flex items-center gap-2">
<ArrowRight className="w-5 h-5" />
{bestSolution.improvements.objective.absolute_change.toExponential(4)}
</div>
</div>
)}
</div>
{/* Design Variables */}
<div className="border-t border-dark-600 pt-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-dark-300">Optimal Design Variables</h3>
{paramEntries.length > 6 && (
<button
onClick={() => setShowAllParams(!showAllParams)}
className="text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1"
>
{showAllParams ? (
<>Show Less <ChevronUp className="w-3 h-3" /></>
) : (
<>Show All ({paramEntries.length}) <ChevronDown className="w-3 h-3" /></>
)}
</button>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{visibleParams.map(([name, value]) => (
<div key={name} className="bg-dark-800 rounded px-3 py-2">
<div className="text-xs text-dark-400 truncate" title={name}>{name}</div>
<div className="text-sm font-mono text-white">
{typeof value === 'number' ? value.toFixed(4) : value}
</div>
</div>
))}
</div>
</div>
</Card>
)}
{/* Export Options */}
<Card className="mb-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Download className="w-5 h-5 text-primary-400" />
Export Data
</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<button
onClick={() => handleExport('csv')}
disabled={exporting !== null}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50"
>
<FileSpreadsheet className="w-8 h-8 text-green-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">CSV</div>
<div className="text-xs text-dark-400">Spreadsheet</div>
</div>
{exporting === 'csv' && <Loader2 className="w-4 h-4 animate-spin ml-auto" />}
</button>
<button
onClick={() => handleExport('json')}
disabled={exporting !== null}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50"
>
<FileJson className="w-8 h-8 text-blue-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">JSON</div>
<div className="text-xs text-dark-400">Full data</div>
</div>
{exporting === 'json' && <Loader2 className="w-4 h-4 animate-spin ml-auto" />}
</button>
<button
onClick={() => handleExport('config')}
disabled={exporting !== null}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50"
>
<Settings className="w-8 h-8 text-purple-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">Config</div>
<div className="text-xs text-dark-400">Settings</div>
</div>
{exporting === 'config' && <Loader2 className="w-4 h-4 animate-spin ml-auto" />}
</button>
<button
onClick={handleDownload}
disabled={!reportContent}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<FileText className="w-8 h-8 text-orange-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">Report</div>
<div className="text-xs text-dark-400">Markdown</div>
</div>
</button>
<button
onClick={handlePrintPDF}
disabled={!reportContent}
className="flex items-center gap-3 p-4 bg-dark-700 hover:bg-dark-600 rounded-lg border border-dark-600 hover:border-dark-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Printer className="w-8 h-8 text-red-400" />
<div className="text-left">
<div className="text-sm font-medium text-white">PDF</div>
<div className="text-xs text-dark-400">Print report</div>
</div>
</button>
</div>
</Card>
{/* Main Content - Report */}
<div className="flex-1 min-h-0">
<Card className="h-full overflow-hidden flex flex-col">
<div className="flex items-center justify-between border-b border-dark-600 pb-4 mb-4">
@@ -175,18 +513,8 @@ export default function Results() {
<span>Loading report...</span>
</div>
) : reportContent ? (
<div className="prose prose-invert prose-sm max-w-none
prose-headings:text-white prose-headings:font-semibold
prose-p:text-dark-300 prose-strong:text-white
prose-code:text-primary-400 prose-code:bg-dark-700 prose-code:px-1 prose-code:rounded
prose-pre:bg-dark-700 prose-pre:border prose-pre:border-dark-600
prose-a:text-primary-400 prose-a:no-underline hover:prose-a:underline
prose-ul:text-dark-300 prose-ol:text-dark-300
prose-li:text-dark-300
prose-table:border-collapse prose-th:border prose-th:border-dark-600 prose-th:p-2 prose-th:bg-dark-700
prose-td:border prose-td:border-dark-600 prose-td:p-2
prose-hr:border-dark-600">
<ReactMarkdown>{reportContent}</ReactMarkdown>
<div className="p-2">
<MarkdownRenderer content={reportContent} />
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-dark-400">

View File

@@ -0,0 +1,780 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Settings,
Target,
Sliders,
AlertTriangle,
Cpu,
Box,
Layers,
Play,
Download,
RefreshCw,
ChevronDown,
ChevronUp,
ArrowUp,
ArrowDown,
CheckCircle,
Info,
FileBox,
FolderOpen,
File
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { Card } from '../components/common/Card';
import { Button } from '../components/common/Button';
import { apiClient, ModelFile } from '../api/client';
interface StudyConfig {
study_name: string;
description?: string;
objectives: {
name: string;
direction: 'minimize' | 'maximize';
unit?: string;
target?: number;
weight?: number;
}[];
design_variables: {
name: string;
type: 'float' | 'int' | 'categorical';
low?: number;
high?: number;
step?: number;
choices?: string[];
unit?: string;
}[];
constraints: {
name: string;
type: 'le' | 'ge' | 'eq';
bound: number;
unit?: string;
}[];
algorithm: {
name: string;
sampler: string;
pruner?: string;
n_trials: number;
timeout?: number;
};
fea_model?: {
software: string;
solver: string;
sim_file?: string;
mesh_elements?: number;
};
extractors?: {
name: string;
type: string;
source?: string;
}[];
}
export default function Setup() {
const navigate = useNavigate();
const { selectedStudy, isInitialized } = useStudy();
const [config, setConfig] = useState<StudyConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['objectives', 'variables', 'constraints', 'algorithm', 'modelFiles'])
);
const [modelFiles, setModelFiles] = useState<ModelFile[]>([]);
const [modelDir, setModelDir] = useState<string>('');
// Redirect if no study selected
useEffect(() => {
if (isInitialized && !selectedStudy) {
navigate('/');
}
}, [selectedStudy, navigate, isInitialized]);
// Load study configuration
useEffect(() => {
if (selectedStudy) {
loadConfig();
loadModelFiles();
}
}, [selectedStudy]);
const loadModelFiles = async () => {
if (!selectedStudy) return;
try {
const data = await apiClient.getModelFiles(selectedStudy.id);
setModelFiles(data.files);
setModelDir(data.model_dir);
} catch (err) {
console.error('Failed to load model files:', err);
}
};
const handleOpenFolder = async () => {
if (!selectedStudy) return;
try {
await apiClient.openFolder(selectedStudy.id, 'model');
} catch (err: any) {
setError(err.message || 'Failed to open folder');
}
};
const loadConfig = async () => {
if (!selectedStudy) return;
setLoading(true);
setError(null);
try {
const response = await apiClient.getStudyConfig(selectedStudy.id);
const rawConfig = response.config;
// Transform backend config format to our StudyConfig format
const transformedConfig: StudyConfig = {
study_name: rawConfig.study_name || selectedStudy.name || selectedStudy.id,
description: rawConfig.description,
objectives: (rawConfig.objectives || []).map((obj: any) => ({
name: obj.name,
direction: obj.direction || 'minimize',
unit: obj.unit || obj.units,
target: obj.target,
weight: obj.weight
})),
design_variables: (rawConfig.design_variables || []).map((dv: any) => ({
name: dv.name,
type: dv.type || 'float',
low: dv.min ?? dv.low,
high: dv.max ?? dv.high,
step: dv.step,
choices: dv.choices,
unit: dv.unit || dv.units
})),
constraints: (rawConfig.constraints || []).map((c: any) => ({
name: c.name,
type: c.type || 'le',
bound: c.max_value ?? c.min_value ?? c.bound ?? 0,
unit: c.unit || c.units
})),
algorithm: {
name: rawConfig.optimizer?.name || rawConfig.algorithm?.name || 'Optuna',
sampler: rawConfig.optimization_settings?.sampler || rawConfig.algorithm?.sampler || 'TPESampler',
pruner: rawConfig.optimization_settings?.pruner || rawConfig.algorithm?.pruner,
n_trials: rawConfig.optimization_settings?.n_trials || rawConfig.trials?.n_trials || selectedStudy.progress.total,
timeout: rawConfig.optimization_settings?.timeout
},
fea_model: rawConfig.fea_model || rawConfig.solver ? {
software: rawConfig.fea_model?.software || rawConfig.solver?.type || 'NX Nastran',
solver: rawConfig.fea_model?.solver || rawConfig.solver?.name || 'SOL 103',
sim_file: rawConfig.sim_file || rawConfig.fea_model?.sim_file,
mesh_elements: rawConfig.fea_model?.mesh_elements
} : undefined,
extractors: rawConfig.extractors
};
setConfig(transformedConfig);
} catch (err: any) {
// If no config endpoint, create mock from available data
setConfig({
study_name: selectedStudy.name || selectedStudy.id,
objectives: [{ name: 'objective', direction: 'minimize' }],
design_variables: [],
constraints: [],
algorithm: {
name: 'Optuna',
sampler: 'TPESampler',
n_trials: selectedStudy.progress.total
}
});
setError('Configuration loaded with limited data');
} finally {
setLoading(false);
}
};
const toggleSection = (section: string) => {
setExpandedSections(prev => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
const handleStartOptimization = async () => {
if (!selectedStudy) return;
try {
await apiClient.startOptimization(selectedStudy.id);
navigate('/dashboard');
} catch (err: any) {
setError(err.message || 'Failed to start optimization');
}
};
const handleExportConfig = () => {
if (!config) return;
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedStudy?.id || 'study'}_config.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Loading state
if (!isInitialized) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p className="text-dark-400">Loading study...</p>
</div>
</div>
);
}
if (!selectedStudy) return null;
// Calculate design space size
const designSpaceSize = config?.design_variables.reduce((acc, v) => {
if (v.type === 'categorical' && v.choices) {
return acc * v.choices.length;
} else if (v.type === 'int' && v.low !== undefined && v.high !== undefined) {
return acc * (v.high - v.low + 1);
}
return acc * 1000; // Approximate for continuous
}, 1) || 0;
return (
<div className="w-full max-w-[2400px] mx-auto px-4">
{/* Header */}
<header className="mb-6 flex items-center justify-between border-b border-dark-600 pb-4">
<div>
<div className="flex items-center gap-3">
<Settings className="w-8 h-8 text-primary-400" />
<div>
<h1 className="text-2xl font-bold text-white">{config?.study_name || selectedStudy.name}</h1>
<p className="text-dark-400 text-sm">Study Configuration</p>
</div>
</div>
{config?.description && (
<p className="text-dark-300 mt-2 max-w-2xl">{config.description}</p>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="secondary"
icon={<RefreshCw className="w-4 h-4" />}
onClick={loadConfig}
disabled={loading}
>
Refresh
</Button>
<Button
variant="secondary"
icon={<Download className="w-4 h-4" />}
onClick={handleExportConfig}
disabled={!config}
>
Export
</Button>
{selectedStudy.status === 'not_started' && (
<Button
variant="primary"
icon={<Play className="w-4 h-4" />}
onClick={handleStartOptimization}
>
Start Optimization
</Button>
)}
</div>
</header>
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-yellow-900/20 border border-yellow-800/30 rounded-lg">
<div className="flex items-center gap-2 text-yellow-400 text-sm">
<Info className="w-4 h-4" />
<span>{error}</span>
</div>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="w-8 h-8 animate-spin text-dark-400" />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-6">
{/* Objectives Panel */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('objectives')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Target className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Objectives</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config?.objectives.length || 0}
</span>
</div>
{expandedSections.has('objectives') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('objectives') && (
<div className="px-4 pb-4 space-y-3">
{config?.objectives.map((obj, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-4 border border-dark-600"
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-white">{obj.name}</span>
<span className={`flex items-center gap-1 text-sm px-2 py-1 rounded ${
obj.direction === 'minimize'
? 'bg-green-500/10 text-green-400'
: 'bg-blue-500/10 text-blue-400'
}`}>
{obj.direction === 'minimize' ? (
<ArrowDown className="w-3 h-3" />
) : (
<ArrowUp className="w-3 h-3" />
)}
{obj.direction}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-dark-400">
{obj.unit && <span>Unit: {obj.unit}</span>}
{obj.target !== undefined && (
<span>Target: {obj.target}</span>
)}
{obj.weight !== undefined && (
<span>Weight: {obj.weight}</span>
)}
</div>
</div>
))}
{config?.objectives.length === 0 && (
<p className="text-dark-500 text-sm italic">No objectives configured</p>
)}
<div className="text-xs text-dark-500 pt-2">
Type: {(config?.objectives.length || 0) > 1 ? 'Multi-Objective' : 'Single-Objective'}
</div>
</div>
)}
</Card>
{/* Design Variables Panel */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('variables')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Sliders className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Design Variables</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config?.design_variables.length || 0}
</span>
</div>
{expandedSections.has('variables') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('variables') && (
<div className="px-4 pb-4 space-y-2">
{config?.design_variables.map((v, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-3 border border-dark-600"
>
<div className="flex items-center justify-between">
<span className="font-medium text-white font-mono text-sm">{v.name}</span>
<span className="text-xs bg-dark-600 text-dark-400 px-2 py-0.5 rounded">
{v.type}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-dark-400 mt-1">
{v.low !== undefined && v.high !== undefined && (
<span>Range: [{v.low}, {v.high}]</span>
)}
{v.step && <span>Step: {v.step}</span>}
{v.unit && <span>{v.unit}</span>}
{v.choices && (
<span>Choices: {v.choices.join(', ')}</span>
)}
</div>
</div>
))}
{config?.design_variables.length === 0 && (
<p className="text-dark-500 text-sm italic">No design variables configured</p>
)}
{designSpaceSize > 0 && (
<div className="text-xs text-dark-500 pt-2">
Design Space: ~{designSpaceSize.toExponential(2)} combinations
</div>
)}
</div>
)}
</Card>
{/* Constraints Panel */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('constraints')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-400" />
<h2 className="text-lg font-semibold text-white">Constraints</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config?.constraints.length || 0}
</span>
</div>
{expandedSections.has('constraints') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('constraints') && (
<div className="px-4 pb-4">
{(config?.constraints.length || 0) > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="text-dark-400 text-left">
<th className="pb-2">Name</th>
<th className="pb-2">Type</th>
<th className="pb-2">Bound</th>
<th className="pb-2">Unit</th>
</tr>
</thead>
<tbody className="text-dark-300">
{config?.constraints.map((c, idx) => (
<tr key={idx} className="border-t border-dark-700">
<td className="py-2 font-mono">{c.name}</td>
<td className="py-2">
{c.type === 'le' ? '≤' : c.type === 'ge' ? '≥' : '='}
</td>
<td className="py-2">{c.bound}</td>
<td className="py-2 text-dark-500">{c.unit || '-'}</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-dark-500 text-sm italic">No constraints configured</p>
)}
</div>
)}
</Card>
</div>
{/* Right Column */}
<div className="space-y-6">
{/* Algorithm Configuration */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('algorithm')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Cpu className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Algorithm Configuration</h2>
</div>
{expandedSections.has('algorithm') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('algorithm') && (
<div className="px-4 pb-4 space-y-3">
<div className="grid grid-cols-2 gap-4">
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Optimizer</div>
<div className="text-white font-medium">{config?.algorithm.name || 'Optuna'}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Sampler</div>
<div className="text-white font-medium">{config?.algorithm.sampler || 'TPE'}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Total Trials</div>
<div className="text-white font-medium">{config?.algorithm.n_trials || selectedStudy.progress.total}</div>
</div>
{config?.algorithm.pruner && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Pruner</div>
<div className="text-white font-medium">{config.algorithm.pruner}</div>
</div>
)}
{config?.algorithm.timeout && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Timeout</div>
<div className="text-white font-medium">{config.algorithm.timeout}s</div>
</div>
)}
</div>
</div>
)}
</Card>
{/* FEA Model Info */}
{config?.fea_model && (
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('model')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Box className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">FEA Model</h2>
</div>
{expandedSections.has('model') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('model') && (
<div className="px-4 pb-4 space-y-3">
<div className="grid grid-cols-2 gap-4">
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Software</div>
<div className="text-white font-medium">{config.fea_model.software}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Solver</div>
<div className="text-white font-medium">{config.fea_model.solver}</div>
</div>
{config.fea_model.mesh_elements && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-1">Mesh Elements</div>
<div className="text-white font-medium">
{config.fea_model.mesh_elements.toLocaleString()}
</div>
</div>
)}
{config.fea_model.sim_file && (
<div className="bg-dark-750 rounded-lg p-3 border border-dark-600 col-span-2">
<div className="text-xs text-dark-400 uppercase mb-1">Simulation File</div>
<div className="text-white font-mono text-sm truncate">
{config.fea_model.sim_file}
</div>
</div>
)}
</div>
</div>
)}
</Card>
)}
{/* NX Model Files */}
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('modelFiles')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<FileBox className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">NX Model Files</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{modelFiles.length}
</span>
</div>
{expandedSections.has('modelFiles') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('modelFiles') && (
<div className="px-4 pb-4 space-y-3">
{/* Open Folder Button */}
<button
onClick={handleOpenFolder}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600 text-dark-200 hover:text-white rounded-lg border border-dark-600 transition-colors"
>
<FolderOpen className="w-4 h-4" />
<span>Open Model Folder</span>
</button>
{/* Model Directory Path */}
{modelDir && (
<div className="text-xs text-dark-500 font-mono truncate" title={modelDir}>
{modelDir}
</div>
)}
{/* File List */}
{modelFiles.length > 0 ? (
<div className="space-y-2">
{modelFiles.map((file, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-3 border border-dark-600"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<File className={`w-4 h-4 ${
file.extension === '.prt' ? 'text-blue-400' :
file.extension === '.sim' ? 'text-green-400' :
file.extension === '.fem' ? 'text-yellow-400' :
file.extension === '.bdf' || file.extension === '.dat' ? 'text-orange-400' :
file.extension === '.op2' ? 'text-purple-400' :
'text-dark-400'
}`} />
<span className="font-medium text-white text-sm truncate" title={file.name}>
{file.name}
</span>
</div>
<span className="text-xs bg-dark-600 text-dark-400 px-2 py-0.5 rounded uppercase">
{file.extension.slice(1)}
</span>
</div>
<div className="flex items-center justify-between mt-1 text-xs text-dark-500">
<span>{file.size_display}</span>
<span>{new Date(file.modified).toLocaleDateString()}</span>
</div>
</div>
))}
</div>
) : (
<p className="text-dark-500 text-sm italic text-center py-4">
No model files found
</p>
)}
{/* File Type Legend */}
{modelFiles.length > 0 && (
<div className="pt-2 border-t border-dark-700">
<div className="flex flex-wrap gap-3 text-xs text-dark-500">
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-blue-400 rounded-full"></span>.prt = Part</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-green-400 rounded-full"></span>.sim = Simulation</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-yellow-400 rounded-full"></span>.fem = FEM</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-orange-400 rounded-full"></span>.bdf = Nastran</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-purple-400 rounded-full"></span>.op2 = Results</span>
</div>
</div>
)}
</div>
)}
</Card>
{/* Extractors */}
{config?.extractors && config.extractors.length > 0 && (
<Card className="overflow-hidden">
<button
onClick={() => toggleSection('extractors')}
className="w-full flex items-center justify-between p-4 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-3">
<Layers className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Extractors</h2>
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded-full">
{config.extractors.length}
</span>
</div>
{expandedSections.has('extractors') ? (
<ChevronUp className="w-5 h-5 text-dark-400" />
) : (
<ChevronDown className="w-5 h-5 text-dark-400" />
)}
</button>
{expandedSections.has('extractors') && (
<div className="px-4 pb-4 space-y-2">
{config.extractors.map((ext, idx) => (
<div
key={idx}
className="bg-dark-750 rounded-lg p-3 border border-dark-600"
>
<div className="flex items-center justify-between">
<span className="font-medium text-white">{ext.name}</span>
<span className="text-xs bg-dark-600 text-dark-400 px-2 py-0.5 rounded">
{ext.type}
</span>
</div>
{ext.source && (
<div className="text-xs text-dark-500 mt-1 font-mono">{ext.source}</div>
)}
</div>
))}
</div>
)}
</Card>
)}
{/* Study Stats */}
<Card title="Current Progress">
<div className="grid grid-cols-2 gap-4">
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600 text-center">
<div className="text-3xl font-bold text-white">
{selectedStudy.progress.current}
</div>
<div className="text-xs text-dark-400 uppercase mt-1">Trials Completed</div>
</div>
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600 text-center">
<div className="text-3xl font-bold text-primary-400">
{selectedStudy.best_value?.toExponential(3) || 'N/A'}
</div>
<div className="text-xs text-dark-400 uppercase mt-1">Best Value</div>
</div>
</div>
{/* Progress Bar */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-dark-400">Progress</span>
<span className="text-white">
{Math.round((selectedStudy.progress.current / selectedStudy.progress.total) * 100)}%
</span>
</div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full transition-all"
style={{
width: `${Math.min((selectedStudy.progress.current / selectedStudy.progress.total) * 100, 100)}%`
}}
/>
</div>
</div>
{/* Status Badge */}
<div className="mt-4 flex items-center justify-center">
<span className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium ${
selectedStudy.status === 'running' ? 'bg-green-500/10 text-green-400' :
selectedStudy.status === 'completed' ? 'bg-blue-500/10 text-blue-400' :
selectedStudy.status === 'paused' ? 'bg-orange-500/10 text-orange-400' :
'bg-dark-600 text-dark-400'
}`}>
{selectedStudy.status === 'completed' && <CheckCircle className="w-4 h-4" />}
{selectedStudy.status === 'running' && <Play className="w-4 h-4" />}
<span className="capitalize">{selectedStudy.status.replace('_', ' ')}</span>
</span>
</div>
</Card>
</div>
</div>
)}
</div>
);
}