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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user