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>
947 lines
41 KiB
TypeScript
947 lines
41 KiB
TypeScript
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>
|
||
);
|
||
}
|