Files
Atomizer/atomizer-dashboard/frontend/src/pages/Dashboard.tsx
Anto01 73a7b9d9f1 feat: Add dashboard chat integration and MCP server
Major changes:
- Dashboard: WebSocket-based chat with session management
- Dashboard: New chat components (ChatPane, ChatInput, ModeToggle)
- Dashboard: Enhanced UI with parallel coordinates chart
- MCP Server: New atomizer-tools server for Claude integration
- Extractors: Enhanced Zernike OPD extractor
- Reports: Improved report generator

New studies (configs and scripts only):
- M1 Mirror: Cost reduction campaign studies
- Simple Beam, Simple Bracket, UAV Arm studies

Note: Large iteration data (2_iterations/, best_design_archive/)
excluded via .gitignore - kept on local Gitea only.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 15:53:55 -05:00

947 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, lazy, Suspense, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { 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 { 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';
import { ConvergencePlot } from '../components/ConvergencePlot';
import { StudyReportViewer } from '../components/StudyReportViewer';
import { ConsoleOutput } from '../components/ConsoleOutput';
import { ZernikeButton } from '../components/ZernikeViewer';
import { ExpandableChart } from '../components/ExpandableChart';
import { CurrentTrialPanel, OptimizerStatePanel } from '../components/tracker';
import { NivoParallelCoordinates } from '../components/charts';
import type { Trial } from '../types';
// Lazy load Plotly components for better initial load performance
const PlotlyParallelCoordinates = lazy(() => import('../components/plotly/PlotlyParallelCoordinates').then(m => ({ default: m.PlotlyParallelCoordinates })));
const PlotlyParetoPlot = lazy(() => import('../components/plotly/PlotlyParetoPlot').then(m => ({ default: m.PlotlyParetoPlot })));
const PlotlyConvergencePlot = lazy(() => import('../components/plotly/PlotlyConvergencePlot').then(m => ({ default: m.PlotlyConvergencePlot })));
const PlotlyParameterImportance = lazy(() => import('../components/plotly/PlotlyParameterImportance').then(m => ({ default: m.PlotlyParameterImportance })));
// Loading placeholder for lazy components
const ChartLoading = () => (
<div className="flex items-center justify-center h-64 text-dark-400">
<div className="animate-pulse">Loading chart...</div>
</div>
);
export default function Dashboard() {
const navigate = useNavigate();
const { selectedStudy, refreshStudies, isInitialized } = useStudy();
const selectedStudyId = selectedStudy?.id || null;
// 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);
const [prunedCount, setPrunedCount] = useState<number>(0);
const [alerts, setAlerts] = useState<Array<{ id: number; type: 'success' | 'warning'; message: string }>>([]);
const [alertIdCounter, setAlertIdCounter] = useState(0);
const [expandedTrials, setExpandedTrials] = useState<Set<number>>(new Set());
const [sortBy, setSortBy] = useState<'performance' | 'chronological'>('performance');
const [trialsPage, setTrialsPage] = useState(0);
const trialsPerPage = 50; // Limit trials per page for performance
const [showOnlyFEA, setShowOnlyFEA] = useState(false); // Filter to show only trials with OP2 results
const [zernikeAvailableTrials, setZernikeAvailableTrials] = useState<Set<number>>(new Set()); // Trials with OP2 files
// 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);
const [paretoFront, setParetoFront] = useState<any[]>([]);
const [allTrialsRaw, setAllTrialsRaw] = useState<any[]>([]); // All trials for parallel coordinates
// Chart library toggle: 'nivo' (dark theme, default), 'plotly' (more interactive), or 'recharts' (simple)
const [chartLibrary, setChartLibrary] = useState<'nivo' | 'plotly' | 'recharts'>('nivo');
// 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);
// Desktop notifications
const { showNotification } = useNotifications();
const previousBestRef = useRef<number>(Infinity);
// Check if there's a pending study in localStorage (user just navigated here)
const pendingStudyId = localStorage.getItem('selectedStudyId');
const isWaitingForStudy = isInitialized && !selectedStudy && !!pendingStudyId;
const showAlert = (type: 'success' | 'warning', message: string) => {
const id = alertIdCounter;
setAlertIdCounter(prev => prev + 1);
setAlerts(prev => [...prev, { id, type, message }]);
setTimeout(() => {
setAlerts(prev => prev.filter(a => a.id !== id));
}, 5000);
};
// WebSocket connection
const { connectionStatus: _connectionStatus } = useOptimizationWebSocket({
studyId: selectedStudyId,
onMessage: (msg) => {
if (msg.type === 'trial_completed') {
const trial = msg.data as Trial;
// Avoid duplicates by checking if trial already exists
setAllTrials(prev => {
const exists = prev.some(t => t.trial_number === trial.trial_number);
if (exists) return prev;
return [...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);
showAlert('warning', `Trial pruned: ${msg.data.pruning_cause}`);
}
}
});
// Load initial trial history when study changes
// PERFORMANCE: Use limit to avoid loading thousands of trials at once
const MAX_TRIALS_LOAD = 300;
useEffect(() => {
if (selectedStudyId) {
setAllTrials([]);
setBestValue(Infinity);
setPrunedCount(0);
setExpandedTrials(new Set());
// Single history fetch with limit - used for both trial list and charts
// This replaces the duplicate fetch calls
fetch(`/api/optimization/studies/${selectedStudyId}/history?limit=${MAX_TRIALS_LOAD}`)
.then(res => res.json())
.then(data => {
// Set trials for the trial list
const validTrials = data.trials.filter((t: any) => t.objective !== null && t.objective !== undefined);
setAllTrials(validTrials);
if (validTrials.length > 0) {
const minObj = Math.min(...validTrials.map((t: any) => t.objective));
setBestValue(minObj);
}
// Transform for charts (parallel coordinates, etc.)
const trialsData = data.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];
}
return {
trial_number: t.trial_number,
values,
params: t.design_variables || {},
user_attrs: t.user_attrs || {},
constraint_satisfied: t.constraint_satisfied !== false,
source: t.source || t.user_attrs?.source || 'FEA'
};
});
setAllTrialsRaw(trialsData);
})
.catch(console.error);
apiClient.getStudyPruning(selectedStudyId)
.then(data => {
setPrunedCount(data.count ?? data.pruned_trials?.length ?? 0);
})
.catch(console.error);
// Fetch metadata (small payload)
fetch(`/api/optimization/studies/${selectedStudyId}/metadata`)
.then(res => res.json())
.then(data => setStudyMetadata(data))
.catch(err => console.error('Failed to load metadata:', err));
// Fetch Pareto front (usually small)
fetch(`/api/optimization/studies/${selectedStudyId}/pareto-front`)
.then(res => res.json())
.then(paretoData => {
if (paretoData.is_multi_objective && paretoData.pareto_front) {
setParetoFront(paretoData.pareto_front);
} else {
setParetoFront([]);
}
})
.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));
// Fetch available Zernike trials (for mirror/zernike studies)
if (selectedStudyId.includes('mirror') || selectedStudyId.includes('zernike')) {
fetch(`/api/optimization/studies/${selectedStudyId}/zernike-available`)
.then(res => res.json())
.then(data => {
setZernikeAvailableTrials(new Set(data.available_trials || []));
})
.catch(err => console.error('Failed to load Zernike available trials:', err));
} else {
setZernikeAvailableTrials(new Set());
}
}
}, [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 and filter trials based on selected options
useEffect(() => {
let filtered = [...allTrials];
// Filter to trials with OP2 results (for Zernike analysis)
if (showOnlyFEA && zernikeAvailableTrials.size > 0) {
filtered = filtered.filter(t => zernikeAvailableTrials.has(t.trial_number));
}
// Sort
if (sortBy === 'performance') {
// Sort by objective (best first)
filtered.sort((a, b) => {
const aObj = a.objective ?? Infinity;
const bObj = b.objective ?? Infinity;
return aObj - bObj;
});
} else {
// Chronological (newest first)
filtered.sort((a, b) => b.trial_number - a.trial_number);
}
setDisplayedTrials(filtered);
setTrialsPage(0); // Reset pagination when filter changes
}, [allTrials, sortBy, showOnlyFEA, zernikeAvailableTrials]);
// Auto-refresh polling for trial history
// PERFORMANCE: Use limit and longer interval for large studies
useEffect(() => {
if (!selectedStudyId) return;
const refreshInterval = setInterval(() => {
// Only fetch latest trials, not the entire history
fetch(`/api/optimization/studies/${selectedStudyId}/history?limit=${MAX_TRIALS_LOAD}`)
.then(res => res.json())
.then(data => {
const validTrials = data.trials.filter((t: any) => t.objective !== null && t.objective !== undefined);
setAllTrials(validTrials);
if (validTrials.length > 0) {
const minObj = Math.min(...validTrials.map((t: any) => t.objective));
setBestValue(minObj);
}
// Also update chart data
const trialsData = data.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];
}
return {
trial_number: t.trial_number,
values,
params: t.design_variables || {},
user_attrs: t.user_attrs || {},
constraint_satisfied: t.constraint_satisfied !== false,
source: t.source || t.user_attrs?.source || 'FEA'
};
});
setAllTrialsRaw(trialsData);
})
.catch(err => console.error('Auto-refresh failed:', err));
}, 15000); // Poll every 15 seconds for performance
return () => clearInterval(refreshInterval);
}, [selectedStudyId]);
// Show loading state while initializing or waiting for study context to sync
if (!isInitialized || isWaitingForStudy) {
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 no study selected and no pending study, show message with link to home
if (!selectedStudy) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<p className="text-dark-400 mb-4">No study selected</p>
<button
onClick={() => navigate('/')}
className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors"
>
Select a Study
</button>
</div>
</div>
);
}
// 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);
const avgObjective = validObjectives.length > 0
? validObjectives.reduce((sum, obj) => sum + obj, 0) / validObjectives.length
: 0;
// Get parameter names
const paramNames = allTrials.length > 0 && allTrials[0].design_variables
? Object.keys(allTrials[0].design_variables)
: [];
// Toggle trial expansion
const toggleTrialExpansion = (trialNumber: number) => {
setExpandedTrials(prev => {
const newSet = new Set(prev);
if (newSet.has(trialNumber)) {
newSet.delete(trialNumber);
} else {
newSet.add(trialNumber);
}
return newSet;
});
};
// Export functions
const exportJSON = () => {
if (allTrials.length === 0) return;
const data = JSON.stringify(allTrials, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedStudyId}_trials.json`;
a.click();
URL.revokeObjectURL(url);
showAlert('success', 'JSON exported successfully!');
};
const exportCSV = () => {
if (allTrials.length === 0) return;
const headers = ['trial_number', 'objective', ...paramNames].join(',');
const rows = allTrials.map(t => [
t.trial_number,
t.objective,
...paramNames.map(k => t.design_variables?.[k] ?? '')
].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 = `${selectedStudyId}_trials.csv`;
a.click();
URL.revokeObjectURL(url);
showAlert('success', 'CSV exported successfully!');
};
return (
<div className="w-full">
{/* Alerts */}
<div className="fixed top-4 right-4 z-50 space-y-2">
{alerts.map(alert => (
<div
key={alert.id}
className={`px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
alert.type === 'success'
? 'bg-green-900 border-l-4 border-green-400 text-green-100'
: 'bg-yellow-900 border-l-4 border-yellow-400 text-yellow-100'
}`}
>
{alert.message}
</div>
))}
</div>
{/* Header */}
<header className="mb-8 flex items-center justify-between border-b border-dark-600 pb-4">
<div>
<h1 className="text-3xl font-bold text-primary-400">Live Dashboard</h1>
<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 />
{selectedStudyId && (
<StudyReportViewer studyId={selectedStudyId} />
)}
<button
onClick={async () => {
if (!selectedStudyId) return;
try {
// Launch Optuna dashboard via API, then open the returned URL
const result = await apiClient.launchOptunaDashboard(selectedStudyId);
window.open(result.url || 'http://localhost:8081', '_blank');
} catch (err) {
// If launch fails (maybe already running), try opening directly
console.warn('Failed to launch dashboard:', err);
window.open('http://localhost:8081', '_blank');
}
}}
className="btn-secondary"
title="Launch Optuna Dashboard for this study"
disabled={!selectedStudyId}
>
Optuna Dashboard
</button>
<button onClick={exportJSON} className="btn-secondary" disabled={allTrials.length === 0}>
Export JSON
</button>
<button onClick={exportCSV} className="btn-secondary" disabled={allTrials.length === 0}>
Export CSV
</button>
{/* Chart library toggle */}
<div className="flex rounded-lg overflow-hidden border border-dark-500 ml-2">
<button
onClick={() => setChartLibrary('nivo')}
className={`px-3 py-1.5 text-sm transition-colors ${
chartLibrary === 'nivo'
? 'bg-primary-500 text-white'
: 'bg-dark-600 text-dark-200 hover:bg-dark-500'
}`}
title="Modern Nivo charts with dark theme (recommended)"
>
Nivo
</button>
<button
onClick={() => setChartLibrary('plotly')}
className={`px-3 py-1.5 text-sm transition-colors ${
chartLibrary === 'plotly'
? 'bg-primary-500 text-white'
: 'bg-dark-600 text-dark-200 hover:bg-dark-500'
}`}
title="Interactive Plotly charts with zoom, pan, and export"
>
Plotly
</button>
<button
onClick={() => setChartLibrary('recharts')}
className={`px-3 py-1.5 text-sm transition-colors ${
chartLibrary === 'recharts'
? 'bg-primary-500 text-white'
: 'bg-dark-600 text-dark-200 hover:bg-dark-500'
}`}
title="Simple Recharts visualization"
>
Simple
</button>
</div>
</div>
</header>
{/* Control Panel - Full Width on Top */}
<div className="mb-6">
<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 */}
<main>
{/* Study Name Header + Metrics in one row */}
<div className="mb-4 pb-3 border-b border-dark-600 flex items-center justify-between">
<div>
{selectedStudyId && (
<>
<h2 className="text-xl font-semibold text-primary-300">
{selectedStudyId}
</h2>
{studyMetadata?.description && (
<p className="text-sm text-dark-400 mt-1">{studyMetadata.description}</p>
)}
</>
)}
</div>
{/* Compact Metrics */}
<div className="flex gap-3">
<div className="text-center px-3">
<div className="text-2xl font-bold text-white">{allTrials.length}</div>
<div className="text-xs text-dark-400">Trials</div>
</div>
<div className="text-center px-3 border-l border-dark-600">
<div className="text-2xl font-bold text-green-400">
{bestValue === Infinity ? '-' : bestValue.toFixed(4)}
</div>
<div className="text-xs text-dark-400">Best</div>
</div>
<div className="text-center px-3 border-l border-dark-600">
<div className="text-2xl font-bold text-blue-400">
{avgObjective > 0 ? avgObjective.toFixed(4) : '-'}
</div>
<div className="text-xs text-dark-400">Avg</div>
</div>
<div className="text-center px-3 border-l border-dark-600">
<div className={`text-2xl font-bold ${prunedCount > 0 ? 'text-red-400' : 'text-green-400'}`}>
{prunedCount}
</div>
<div className="text-xs text-dark-400">Pruned</div>
</div>
</div>
</div>
{/* Pareto Front - Full Width */}
{selectedStudyId && paretoFront.length > 0 && studyMetadata && studyMetadata.objectives && (
<div className="mb-4">
<ExpandableChart
title="Pareto Front"
subtitle={`${paretoFront.length} Pareto-optimal solutions | ${studyMetadata.sampler || 'NSGA-II'} | ${studyMetadata.objectives?.length || 2} objectives`}
>
{chartLibrary === 'plotly' ? (
<Suspense fallback={<ChartLoading />}>
<PlotlyParetoPlot
trials={allTrialsRaw}
paretoFront={paretoFront}
objectives={studyMetadata.objectives}
height={300}
/>
</Suspense>
) : (
<ParetoPlot
paretoData={paretoFront}
objectives={studyMetadata.objectives}
allTrials={allTrialsRaw}
/>
)}
</ExpandableChart>
</div>
)}
{/* Parallel Coordinates - Full Width */}
{allTrialsRaw.length > 0 && studyMetadata && studyMetadata.objectives && studyMetadata.design_variables && (
<div className="mb-4">
<ExpandableChart
title="Parallel Coordinates"
subtitle={`${allTrialsRaw.length} trials - Design Variables → Objectives`}
>
{chartLibrary === 'nivo' ? (
<NivoParallelCoordinates
trials={allTrialsRaw}
objectives={studyMetadata.objectives}
designVariables={studyMetadata.design_variables}
paretoFront={paretoFront}
height={380}
/>
) : chartLibrary === 'plotly' ? (
<Suspense fallback={<ChartLoading />}>
<PlotlyParallelCoordinates
trials={allTrialsRaw}
objectives={studyMetadata.objectives}
designVariables={studyMetadata.design_variables}
paretoFront={paretoFront}
height={350}
/>
</Suspense>
) : (
<ParallelCoordinatesPlot
paretoData={allTrialsRaw}
objectives={studyMetadata.objectives}
designVariables={studyMetadata.design_variables}
paretoFront={paretoFront}
/>
)}
</ExpandableChart>
</div>
)}
{/* Convergence Plot - Full Width */}
{allTrialsRaw.length > 0 && (
<div className="mb-4">
<ExpandableChart
title="Convergence"
subtitle={`Best ${studyMetadata?.objectives?.[0]?.name || 'Objective'} over ${allTrialsRaw.length} trials`}
>
{chartLibrary === 'plotly' ? (
<Suspense fallback={<ChartLoading />}>
<PlotlyConvergencePlot
trials={allTrialsRaw}
objectiveIndex={0}
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
direction="minimize"
height={280}
/>
</Suspense>
) : (
<ConvergencePlot
trials={allTrialsRaw}
objectiveIndex={0}
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
direction="minimize"
/>
)}
</ExpandableChart>
</div>
)}
{/* Parameter Importance - Full Width */}
{allTrialsRaw.length > 0 && (studyMetadata?.design_variables?.length > 0 || (allTrialsRaw[0]?.params && Object.keys(allTrialsRaw[0].params).length > 0)) && (
<div className="mb-4">
<ExpandableChart
title="Parameter Importance"
subtitle={`Correlation with ${studyMetadata?.objectives?.[0]?.name || 'Objective'}`}
>
{chartLibrary === 'plotly' ? (
<Suspense fallback={<ChartLoading />}>
<PlotlyParameterImportance
trials={allTrialsRaw}
designVariables={
studyMetadata?.design_variables?.length > 0
? studyMetadata.design_variables
: Object.keys(allTrialsRaw[0]?.params || {}).map(name => ({ name }))
}
objectiveIndex={0}
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
height={280}
/>
</Suspense>
) : (
<ParameterImportanceChart
trials={allTrialsRaw}
designVariables={
studyMetadata?.design_variables?.length > 0
? studyMetadata.design_variables
: Object.keys(allTrialsRaw[0]?.params || {}).map(name => ({ name }))
}
objectiveIndex={0}
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
/>
)}
</ExpandableChart>
</div>
)}
{/* Trial History with Sort Controls and Pagination */}
<Card
title={
<div className="flex items-center justify-between w-full">
<span>Trial History ({displayedTrials.length}{showOnlyFEA ? ' with OP2' : ''} trials{showOnlyFEA && allTrials.length !== displayedTrials.length ? ` of ${allTrials.length}` : ''})</span>
<div className="flex gap-2 items-center">
{/* OP2/Zernike toggle - only show for mirror/zernike studies */}
{selectedStudyId && (selectedStudyId.includes('mirror') || selectedStudyId.includes('zernike')) && zernikeAvailableTrials.size > 0 && (
<button
onClick={() => setShowOnlyFEA(!showOnlyFEA)}
className={`px-3 py-1 rounded text-sm ${
showOnlyFEA
? 'bg-green-600 text-white'
: 'bg-dark-500 text-dark-200 hover:bg-dark-400'
}`}
title={`Show only ${zernikeAvailableTrials.size} trials with OP2 results (for Zernike analysis)`}
>
OP2 Only ({zernikeAvailableTrials.size})
</button>
)}
<button
onClick={() => setSortBy('performance')}
className={`px-3 py-1 rounded text-sm ${
sortBy === 'performance'
? 'bg-primary-500 text-white'
: 'bg-dark-500 text-dark-200 hover:bg-dark-400'
}`}
>
Best First
</button>
<button
onClick={() => setSortBy('chronological')}
className={`px-3 py-1 rounded text-sm ${
sortBy === 'chronological'
? 'bg-primary-500 text-white'
: 'bg-dark-500 text-dark-200 hover:bg-dark-400'
}`}
>
Newest First
</button>
{/* Pagination controls */}
{displayedTrials.length > trialsPerPage && (
<div className="flex items-center gap-1 ml-2 border-l border-dark-500 pl-2">
<button
onClick={() => setTrialsPage(Math.max(0, trialsPage - 1))}
disabled={trialsPage === 0}
className="px-2 py-1 text-sm bg-dark-500 rounded disabled:opacity-50 hover:bg-dark-400"
>
</button>
<span className="text-xs text-dark-300 px-2">
{trialsPage + 1}/{Math.ceil(displayedTrials.length / trialsPerPage)}
</span>
<button
onClick={() => setTrialsPage(Math.min(Math.ceil(displayedTrials.length / trialsPerPage) - 1, trialsPage + 1))}
disabled={trialsPage >= Math.ceil(displayedTrials.length / trialsPerPage) - 1}
className="px-2 py-1 text-sm bg-dark-500 rounded disabled:opacity-50 hover:bg-dark-400"
>
</button>
</div>
)}
</div>
</div>
}
>
<div className="space-y-2 max-h-[600px] overflow-y-auto">
{displayedTrials.length > 0 ? (
displayedTrials.slice(trialsPage * trialsPerPage, (trialsPage + 1) * trialsPerPage).map((trial, idx) => {
const isExpanded = expandedTrials.has(trial.trial_number);
const isBest = trial.objective === bestValue;
return (
<div
key={`trial-${trial.trial_number}-${idx}`}
className={`rounded-lg transition-all duration-200 cursor-pointer ${
isBest
? 'bg-green-900 border-l-4 border-green-400'
: 'bg-dark-500 hover:bg-dark-400'
}`}
onClick={() => toggleTrialExpansion(trial.trial_number)}
>
{/* Collapsed View */}
<div className="p-3">
<div className="flex justify-between items-center">
<span className="font-semibold text-primary-400">
Trial #{trial.trial_number}
{isBest && <span className="ml-2 text-xs bg-green-700 text-green-100 px-2 py-1 rounded">BEST</span>}
</span>
<div className="flex items-center gap-3">
<span className={`font-mono text-lg ${
isBest ? 'text-green-400 font-bold' : 'text-dark-100'
}`}>
{trial.objective !== null && trial.objective !== undefined
? trial.objective.toFixed(4)
: 'N/A'}
</span>
{/* Zernike viewer button - only show for mirror/Zernike studies */}
{selectedStudyId && (selectedStudyId.includes('mirror') || selectedStudyId.includes('zernike')) && (
<ZernikeButton
studyId={selectedStudyId}
trialNumber={trial.trial_number}
compact
/>
)}
<span className="text-dark-400 text-sm">
{isExpanded ? '▼' : '▶'}
</span>
</div>
</div>
{/* Quick Preview - Show ALL metrics */}
{!isExpanded && trial.results && Object.keys(trial.results).length > 0 && (
<div className="text-xs text-primary-300 flex flex-wrap gap-3 mt-2">
{Object.entries(trial.results).slice(0, 6).map(([key, val]) => {
// Format value based on type
const formatValue = (v: unknown): string => {
if (typeof v === 'number') {
// Use fewer decimals for quick preview
return Math.abs(v) < 0.01 ? v.toExponential(2) : v.toFixed(2);
}
if (Array.isArray(v)) return `[${v.length}]`;
return String(v);
};
// Format key: snake_case to Title Case, abbreviate long names
const formatKey = (k: string): string => {
const short = k.replace(/_/g, ' ')
.replace(/rel /g, 'Δ')
.replace(/filtered rms/g, 'fRMS')
.replace(/global rms/g, 'gRMS')
.replace(/ vs /g, '/')
.replace(/mfg /g, '')
.replace(/optician workload/g, 'work');
return short.length > 15 ? short.slice(0, 12) + '...' : short;
};
return (
<span key={key} title={`${key}: ${val}`}>
{formatKey(key)}: {formatValue(val)}
</span>
);
})}
{Object.keys(trial.results).length > 6 && (
<span className="text-dark-400">+{Object.keys(trial.results).length - 6} more</span>
)}
</div>
)}
</div>
{/* Expanded View */}
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
{/* Design Variables */}
{trial.design_variables && Object.keys(trial.design_variables).length > 0 && (
<div className="border-t border-dark-400 pt-3">
<h4 className="text-sm font-semibold text-dark-200 mb-2">Design Variables</h4>
<div className="grid grid-cols-2 gap-2 text-xs">
{Object.entries(trial.design_variables).map(([key, val]) => (
<div key={key} className="flex justify-between bg-dark-600 px-2 py-1 rounded">
<span className="text-dark-300">{key}:</span>
<span className="text-dark-100 font-mono">{val.toFixed(4)}</span>
</div>
))}
</div>
</div>
)}
{/* Results */}
{trial.results && Object.keys(trial.results).length > 0 && (
<div className="border-t border-dark-400 pt-3">
<h4 className="text-sm font-semibold text-dark-200 mb-2">Extracted Results</h4>
<div className="grid grid-cols-2 gap-2 text-xs">
{Object.entries(trial.results).map(([key, val]) => (
<div key={key} className="flex justify-between bg-dark-600 px-2 py-1 rounded">
<span className="text-dark-300">{key}:</span>
<span className="text-primary-300 font-mono">
{typeof val === 'number' ? val.toFixed(4) : String(val)}
</span>
</div>
))}
</div>
</div>
)}
{/* All User Attributes */}
{trial.user_attrs && Object.keys(trial.user_attrs).length > 0 && (
<div className="border-t border-dark-400 pt-3">
<h4 className="text-sm font-semibold text-dark-200 mb-2">All Attributes</h4>
<div className="max-h-48 overflow-y-auto">
<pre className="text-xs text-dark-300 bg-dark-700 p-2 rounded">
{JSON.stringify(trial.user_attrs, null, 2)}
</pre>
</div>
</div>
)}
{/* Timestamps */}
{trial.start_time && trial.end_time && (
<div className="border-t border-dark-400 pt-3 text-xs text-dark-400">
<div className="flex justify-between">
<span>Duration:</span>
<span>
{((new Date(trial.end_time).getTime() - new Date(trial.start_time).getTime()) / 1000).toFixed(1)}s
</span>
</div>
</div>
)}
{/* Zernike Analysis Button - Full size in expanded view */}
{selectedStudyId && (selectedStudyId.includes('mirror') || selectedStudyId.includes('zernike')) && (
<div className="border-t border-dark-400 pt-3 mt-3">
<ZernikeButton
studyId={selectedStudyId}
trialNumber={trial.trial_number}
/>
</div>
)}
</div>
)}
</div>
);
})
) : (
<div className="text-center py-8 text-dark-300">
No trials yet. Waiting for optimization to start...
</div>
)}
</div>
</Card>
{/* Console Output - at the bottom */}
<div className="mt-4">
<ConsoleOutput
studyId={selectedStudyId}
refreshInterval={2000}
maxLines={150}
/>
</div>
</main>
</div>
{/* Config Editor Modal */}
{showConfigEditor && selectedStudyId && (
<ConfigEditor
studyId={selectedStudyId}
onClose={() => setShowConfigEditor(false)}
onSaved={() => refreshStudies()}
/>
)}
</div>
);
}