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

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