feat: Add optimization execution and live results overlay to canvas
Phase 2 - Execution Bridge: - Update /start endpoint to fallback to generic runner when no study script exists - Auto-detect model files (.prt, .sim) from 1_setup/model/ directory - Pass atomizer_spec.json path to generic runner Phase 3 - Live Monitoring & Results Overlay: - Add ResultBadge component for displaying values on canvas nodes - Extend schema with resultValue and isFeasible fields - Update DesignVarNode, ObjectiveNode, ConstraintNode, ExtractorNode to show results - Add Run/Stop buttons and Results toggle to SpecRenderer - Poll /status endpoint every 3s and map best_trial values to nodes - Show green/red badges for constraint feasibility
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useEffect, useMemo, useState, DragEvent } from 'react';
|
||||
import { Play, Square, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
@@ -201,6 +202,72 @@ function SpecRendererInner({
|
||||
const wsStudyId = enableWebSocket ? storeStudyId : null;
|
||||
const { status: wsStatus } = useSpecWebSocket(wsStudyId);
|
||||
|
||||
// Optimization execution state
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [bestTrial, setBestTrial] = useState<any>(null);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
|
||||
// Poll optimization status
|
||||
useEffect(() => {
|
||||
if (!studyId) return;
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/optimization/studies/${studyId}/status`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setIsRunning(data.status === 'running');
|
||||
|
||||
if (data.best_trial) {
|
||||
setBestTrial(data.best_trial);
|
||||
// Auto-show results if running or completed and not explicitly disabled
|
||||
if ((data.status === 'running' || data.status === 'completed') && !showResults) {
|
||||
setShowResults(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail on polling
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
const interval = setInterval(checkStatus, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [studyId]);
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!studyId) return;
|
||||
setIsStarting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/optimization/studies/${studyId}/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ trials: spec?.optimization?.budget?.max_trials || 50 })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || 'Failed to start');
|
||||
}
|
||||
setIsRunning(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start optimization');
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!studyId) return;
|
||||
try {
|
||||
await fetch(`/api/optimization/studies/${studyId}/stop`, { method: 'POST' });
|
||||
setIsRunning(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to stop optimization');
|
||||
}
|
||||
};
|
||||
|
||||
// Load spec on mount if studyId provided
|
||||
useEffect(() => {
|
||||
if (studyId) {
|
||||
@@ -214,8 +281,49 @@ function SpecRendererInner({
|
||||
|
||||
// Convert spec to ReactFlow nodes
|
||||
const nodes = useMemo(() => {
|
||||
return specToNodes(spec);
|
||||
}, [spec]);
|
||||
const baseNodes = specToNodes(spec);
|
||||
|
||||
if (showResults && bestTrial) {
|
||||
return baseNodes.map(node => {
|
||||
const newData = { ...node.data };
|
||||
|
||||
// Map results to nodes
|
||||
if (node.type === 'designVar') {
|
||||
const val = bestTrial.design_variables?.[newData.expressionName];
|
||||
if (val !== undefined) newData.resultValue = val;
|
||||
} else if (node.type === 'objective') {
|
||||
const outputName = newData.outputName;
|
||||
if (outputName && bestTrial.results && bestTrial.results[outputName] !== undefined) {
|
||||
newData.resultValue = bestTrial.results[outputName];
|
||||
}
|
||||
} else if (node.type === 'constraint') {
|
||||
const outputName = newData.outputName;
|
||||
if (outputName && bestTrial.results && bestTrial.results[outputName] !== undefined) {
|
||||
const val = bestTrial.results[outputName];
|
||||
newData.resultValue = val;
|
||||
|
||||
// Check feasibility
|
||||
if (newData.operator === '<=' && newData.value !== undefined) newData.isFeasible = val <= newData.value;
|
||||
else if (newData.operator === '>=' && newData.value !== undefined) newData.isFeasible = val >= newData.value;
|
||||
else if (newData.operator === '<' && newData.value !== undefined) newData.isFeasible = val < newData.value;
|
||||
else if (newData.operator === '>' && newData.value !== undefined) newData.isFeasible = val > newData.value;
|
||||
else if (newData.operator === '==' && newData.value !== undefined) newData.isFeasible = Math.abs(val - newData.value) < 1e-6;
|
||||
}
|
||||
} else if (node.type === 'extractor') {
|
||||
if (newData.outputNames && newData.outputNames.length > 0 && bestTrial.results) {
|
||||
const firstOut = newData.outputNames[0];
|
||||
if (bestTrial.results[firstOut] !== undefined) {
|
||||
newData.resultValue = bestTrial.results[firstOut];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...node, data: newData };
|
||||
});
|
||||
}
|
||||
|
||||
return baseNodes;
|
||||
}, [spec, showResults, bestTrial]);
|
||||
|
||||
// Convert spec to ReactFlow edges with selection styling
|
||||
const edges = useMemo(() => {
|
||||
@@ -527,6 +635,47 @@ function SpecRendererInner({
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="absolute bottom-4 right-4 z-10 flex gap-2">
|
||||
{bestTrial && (
|
||||
<button
|
||||
onClick={() => setShowResults(!showResults)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors border ${
|
||||
showResults
|
||||
? 'bg-primary-600/90 text-white border-primary-500 hover:bg-primary-500'
|
||||
: 'bg-dark-800 text-dark-300 border-dark-600 hover:text-white hover:border-dark-500'
|
||||
}`}
|
||||
title={showResults ? "Hide Results" : "Show Best Trial Results"}
|
||||
>
|
||||
{showResults ? <Eye size={16} /> : <EyeOff size={16} />}
|
||||
<span className="text-sm font-medium">Results</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRunning ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 shadow-lg transition-colors font-medium"
|
||||
>
|
||||
<Square size={16} fill="currentColor" />
|
||||
Stop Optimization
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={isStarting}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-500 shadow-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isStarting ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={16} fill="currentColor" />
|
||||
)}
|
||||
Run Optimization
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Study name badge */}
|
||||
<div className="absolute bottom-4 left-4 z-10 px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
|
||||
<span className="text-sm text-dark-300">{spec.meta.study_name}</span>
|
||||
|
||||
Reference in New Issue
Block a user