feat: Add WebSocket live updates and convergence visualization

Phase 4 - Live Updates:
- Create useOptimizationStream hook for real-time trial updates
- Replace polling with WebSocket subscription in SpecRenderer
- Auto-report errors to ErrorPanel via panel store
- Add progress tracking (FEA count, NN count, best trial)

Phase 5 - Convergence Visualization:
- Add ConvergenceSparkline component for mini line charts
- Add ProgressRing component for circular progress indicator
- Update ObjectiveNode to show convergence trend sparkline
- Add history field to ObjectiveNodeData schema
- Add live progress indicator centered on canvas when running

Bug fixes:
- Fix TypeScript errors in FloatingIntrospectionPanel (type casts)
- Fix ValidationPanel using wrong store method (selectNode vs setSelectedNodeId)
- Fix NodeConfigPanelV2 unused state variable
- Fix specValidator source.extractor_id path
- Clean up unused imports across components
This commit is contained in:
2026-01-21 21:48:35 -05:00
parent c224b16ac3
commit 2cb8dccc3a
10 changed files with 764 additions and 167 deletions

View File

@@ -39,7 +39,9 @@ import {
} from '../../hooks/useSpecStore'; } from '../../hooks/useSpecStore';
import { useSpecWebSocket } from '../../hooks/useSpecWebSocket'; import { useSpecWebSocket } from '../../hooks/useSpecWebSocket';
import { usePanelStore } from '../../hooks/usePanelStore'; import { usePanelStore } from '../../hooks/usePanelStore';
import { useOptimizationStream } from '../../hooks/useOptimizationStream';
import { ConnectionStatusIndicator } from './ConnectionStatusIndicator'; import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
import { ProgressRing } from './visualization/ConvergenceSparkline';
import { CanvasNodeData } from '../../lib/canvas/schema'; import { CanvasNodeData } from '../../lib/canvas/schema';
import { validateSpec, canRunOptimization } from '../../lib/validation/specValidator'; import { validateSpec, canRunOptimization } from '../../lib/validation/specValidator';
@@ -207,107 +209,66 @@ function SpecRendererInner({
// Panel store for validation and error panels // Panel store for validation and error panels
const { setValidationData, addError, openPanel } = usePanelStore(); const { setValidationData, addError, openPanel } = usePanelStore();
// Optimization WebSocket stream for real-time updates
const {
status: optimizationStatus,
progress: wsProgress,
bestTrial: wsBestTrial,
recentTrials,
} = useOptimizationStream(studyId, {
autoReportErrors: true,
onTrialComplete: (trial) => {
console.log('[SpecRenderer] Trial completed:', trial.trial_number);
},
onNewBest: (best) => {
console.log('[SpecRenderer] New best found:', best.value);
setShowResults(true); // Auto-show results when new best found
},
});
// Optimization execution state // Optimization execution state
const [isRunning, setIsRunning] = useState(false); const isRunning = optimizationStatus === 'running';
const [isStarting, setIsStarting] = useState(false); const [isStarting, setIsStarting] = useState(false);
const [bestTrial, setBestTrial] = useState<any>(null);
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked'); const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked');
// Track last seen error timestamp to avoid duplicates // Build trial history for sparklines (extract objective values from recent trials)
const lastErrorTimestamp = useRef<number>(0); const trialHistory = useMemo(() => {
const history: Record<string, number[]> = {};
// Poll optimization status for (const trial of recentTrials) {
useEffect(() => { // Map objective values - assumes single objective for now
if (!studyId) return; if (trial.objective !== null) {
const key = 'primary';
const checkStatus = async () => { if (!history[key]) history[key] = [];
try { history[key].push(trial.objective);
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);
}
}
// Handle errors from the optimization process
if (data.error && data.error_timestamp && data.error_timestamp > lastErrorTimestamp.current) {
lastErrorTimestamp.current = data.error_timestamp;
// Classify the error based on the message
let errorType: 'nx_crash' | 'solver_fail' | 'extractor_error' | 'config_error' | 'system_error' | 'unknown' = 'unknown';
const errorMsg = data.error.toLowerCase();
if (errorMsg.includes('nx') || errorMsg.includes('siemens') || errorMsg.includes('journal')) {
errorType = 'nx_crash';
} else if (errorMsg.includes('solver') || errorMsg.includes('nastran') || errorMsg.includes('convergence')) {
errorType = 'solver_fail';
} else if (errorMsg.includes('extractor') || errorMsg.includes('extract') || errorMsg.includes('op2')) {
errorType = 'extractor_error';
} else if (errorMsg.includes('config') || errorMsg.includes('spec') || errorMsg.includes('parameter')) {
errorType = 'config_error';
} else if (errorMsg.includes('system') || errorMsg.includes('permission') || errorMsg.includes('disk')) {
errorType = 'system_error';
}
// Generate suggestions based on error type
const suggestions: string[] = [];
switch (errorType) {
case 'nx_crash':
suggestions.push('Check if NX is running and licensed');
suggestions.push('Verify the model file is not corrupted');
suggestions.push('Try closing and reopening NX');
break;
case 'solver_fail':
suggestions.push('Check the mesh quality in the FEM file');
suggestions.push('Verify boundary conditions are properly defined');
suggestions.push('Review the solver settings');
break;
case 'extractor_error':
suggestions.push('Verify the OP2 file was created successfully');
suggestions.push('Check if the extractor type matches the analysis');
suggestions.push('For custom extractors, review the code for errors');
break;
case 'config_error':
suggestions.push('Run validation to check the spec');
suggestions.push('Verify all design variables have valid bounds');
break;
default:
suggestions.push('Check the optimization logs for more details');
}
addError({
type: errorType,
trial: data.current_trial,
message: data.error,
details: data.error_details,
recoverable: errorType !== 'config_error',
suggestions,
timestamp: data.error_timestamp || Date.now(),
});
}
// Handle failed status
if (data.status === 'failed' && data.error) {
setIsRunning(false);
}
}
} catch (e) {
// Silent fail on polling - network issues shouldn't spam errors
console.debug('Status poll failed:', e);
} }
// Could also extract individual params/results for multi-objective
}
// Reverse so oldest is first (for sparkline)
for (const key of Object.keys(history)) {
history[key].reverse();
}
return history;
}, [recentTrials]);
// Build best trial data for node display
const bestTrial = useMemo((): {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
results: Record<string, number>;
} | null => {
if (!wsBestTrial) return null;
return {
trial_number: wsBestTrial.trial_number,
objective: wsBestTrial.value,
design_variables: wsBestTrial.params,
results: { primary: wsBestTrial.value, ...wsBestTrial.params },
}; };
}, [wsBestTrial]);
checkStatus();
const interval = setInterval(checkStatus, 3000); // Note: Polling removed - now using WebSocket via useOptimizationStream hook
return () => clearInterval(interval); // The hook handles: status updates, best trial updates, error reporting
}, [studyId, addError]);
// Validate the spec and show results in panel // Validate the spec and show results in panel
const handleValidate = useCallback(() => { const handleValidate = useCallback(() => {
@@ -359,7 +320,7 @@ function SpecRendererInner({
const err = await res.json(); const err = await res.json();
throw new Error(err.detail || 'Failed to start'); throw new Error(err.detail || 'Failed to start');
} }
setIsRunning(true); // isRunning is now derived from WebSocket state (optimizationStatus === 'running')
setValidationStatus('unchecked'); // Clear validation status when running setValidationStatus('unchecked'); // Clear validation status when running
} catch (e) { } catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to start optimization'; const errorMessage = e instanceof Error ? e.message : 'Failed to start optimization';
@@ -386,7 +347,7 @@ function SpecRendererInner({
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to stop'); throw new Error(err.detail || 'Failed to stop');
} }
setIsRunning(false); // isRunning will update via WebSocket when optimization actually stops
} catch (e) { } catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to stop optimization'; const errorMessage = e instanceof Error ? e.message : 'Failed to stop optimization';
setError(errorMessage); setError(errorMessage);
@@ -415,47 +376,56 @@ function SpecRendererInner({
const nodes = useMemo(() => { const nodes = useMemo(() => {
const baseNodes = specToNodes(spec); const baseNodes = specToNodes(spec);
if (showResults && bestTrial) { // Always map nodes to include history for sparklines (even if not showing results)
return baseNodes.map(node => { return baseNodes.map(node => {
const newData = { ...node.data }; // Create a mutable copy with explicit any type for dynamic property assignment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// Map results to nodes const newData: any = { ...node.data };
if (node.type === 'designVar') {
// Add history for sparklines on objective nodes
if (node.type === 'objective') {
newData.history = trialHistory['primary'] || [];
}
// Map results to nodes when showing results
if (showResults && bestTrial) {
if (node.type === 'designVar' && newData.expressionName) {
const val = bestTrial.design_variables?.[newData.expressionName]; const val = bestTrial.design_variables?.[newData.expressionName];
if (val !== undefined) newData.resultValue = val; if (val !== undefined) newData.resultValue = val;
} else if (node.type === 'objective') { } else if (node.type === 'objective') {
const outputName = newData.outputName; const outputName = newData.outputName;
if (outputName && bestTrial.results && bestTrial.results[outputName] !== undefined) { if (outputName && bestTrial.results?.[outputName] !== undefined) {
newData.resultValue = bestTrial.results[outputName]; newData.resultValue = bestTrial.results[outputName];
} }
} else if (node.type === 'constraint') { } else if (node.type === 'constraint') {
const outputName = newData.outputName; const outputName = newData.outputName;
if (outputName && bestTrial.results && bestTrial.results[outputName] !== undefined) { if (outputName && bestTrial.results?.[outputName] !== undefined) {
const val = bestTrial.results[outputName]; const val = bestTrial.results[outputName];
newData.resultValue = val; newData.resultValue = val;
// Check feasibility // Check feasibility
if (newData.operator === '<=' && newData.value !== undefined) newData.isFeasible = val <= newData.value; const op = newData.operator;
else if (newData.operator === '>=' && newData.value !== undefined) newData.isFeasible = val >= newData.value; const threshold = newData.value;
else if (newData.operator === '<' && newData.value !== undefined) newData.isFeasible = val < newData.value; if (op === '<=' && threshold !== undefined) newData.isFeasible = val <= threshold;
else if (newData.operator === '>' && newData.value !== undefined) newData.isFeasible = val > newData.value; else if (op === '>=' && threshold !== undefined) newData.isFeasible = val >= threshold;
else if (newData.operator === '==' && newData.value !== undefined) newData.isFeasible = Math.abs(val - newData.value) < 1e-6; else if (op === '<' && threshold !== undefined) newData.isFeasible = val < threshold;
} else if (op === '>' && threshold !== undefined) newData.isFeasible = val > threshold;
else if (op === '==' && threshold !== undefined) newData.isFeasible = Math.abs(val - threshold) < 1e-6;
}
} else if (node.type === 'extractor') { } else if (node.type === 'extractor') {
if (newData.outputNames && newData.outputNames.length > 0 && bestTrial.results) { const outputNames = newData.outputNames;
const firstOut = newData.outputNames[0]; if (outputNames && outputNames.length > 0 && bestTrial.results) {
if (bestTrial.results[firstOut] !== undefined) { const firstOut = outputNames[0];
newData.resultValue = bestTrial.results[firstOut]; if (bestTrial.results[firstOut] !== undefined) {
} newData.resultValue = bestTrial.results[firstOut];
} }
}
} }
}
return { ...node, data: newData };
}); return { ...node, data: newData };
} });
}, [spec, showResults, bestTrial, trialHistory]);
return baseNodes;
}, [spec, showResults, bestTrial]);
// Convert spec to ReactFlow edges with selection styling // Convert spec to ReactFlow edges with selection styling
const edges = useMemo(() => { const edges = useMemo(() => {
@@ -841,6 +811,39 @@ function SpecRendererInner({
<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"> <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> <span className="text-sm text-dark-300">{spec.meta.study_name}</span>
</div> </div>
{/* Progress indicator when running */}
{isRunning && wsProgress && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-3 px-4 py-2 bg-dark-800/95 backdrop-blur rounded-lg border border-dark-600 shadow-lg">
<ProgressRing
progress={wsProgress.percentage}
size={36}
strokeWidth={3}
color="#10b981"
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-white">
Trial {wsProgress.current} / {wsProgress.total}
</span>
<span className="text-xs text-dark-400">
{wsProgress.fea_count > 0 && `${wsProgress.fea_count} FEA`}
{wsProgress.fea_count > 0 && wsProgress.nn_count > 0 && ' + '}
{wsProgress.nn_count > 0 && `${wsProgress.nn_count} NN`}
{wsProgress.fea_count === 0 && wsProgress.nn_count === 0 && 'Running...'}
</span>
</div>
{wsBestTrial && (
<div className="flex flex-col border-l border-dark-600 pl-3 ml-1">
<span className="text-xs text-dark-400">Best</span>
<span className="text-sm font-medium text-emerald-400">
{typeof wsBestTrial.value === 'number'
? wsBestTrial.value.toFixed(4)
: wsBestTrial.value}
</span>
</div>
)}
</div>
)}
</div> </div>
); );
} }

View File

@@ -3,14 +3,37 @@ import { NodeProps } from 'reactflow';
import { Target } from 'lucide-react'; import { Target } from 'lucide-react';
import { BaseNode } from './BaseNode'; import { BaseNode } from './BaseNode';
import { ResultBadge } from './ResultBadge'; import { ResultBadge } from './ResultBadge';
import { ConvergenceSparkline } from '../visualization/ConvergenceSparkline';
import { ObjectiveNodeData } from '../../../lib/canvas/schema'; import { ObjectiveNodeData } from '../../../lib/canvas/schema';
function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) { function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) {
const { data } = props; const { data } = props;
const hasHistory = data.history && data.history.length > 1;
return ( return (
<BaseNode {...props} icon={<Target size={16} />} iconColor="text-rose-400"> <BaseNode {...props} icon={<Target size={16} />} iconColor="text-rose-400">
<ResultBadge value={data.resultValue} label="Best" /> <div className="flex flex-col gap-1">
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'} <div className="flex items-center justify-between">
<span className="text-sm">
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
</span>
<ResultBadge value={data.resultValue} label="Best" />
</div>
{/* Convergence sparkline */}
{hasHistory && (
<div className="mt-1 -mb-1">
<ConvergenceSparkline
values={data.history!}
width={120}
height={20}
direction={data.direction || 'minimize'}
color={data.direction === 'maximize' ? '#34d399' : '#60a5fa'}
showBest={true}
/>
</div>
)}
</div>
</BaseNode> </BaseNode>
); );
} }

View File

@@ -15,27 +15,15 @@ import {
Plus, Plus,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
FileBox,
Cpu, Cpu,
SlidersHorizontal, SlidersHorizontal,
AlertTriangle,
Scale, Scale,
Link,
Box,
Settings2,
GitBranch,
File,
Grid3x3,
Target,
Zap,
Layers,
Minimize2, Minimize2,
Maximize2, Maximize2,
} from 'lucide-react'; } from 'lucide-react';
import { import {
useIntrospectionPanel, useIntrospectionPanel,
usePanelStore, usePanelStore,
IntrospectionData,
} from '../../../hooks/usePanelStore'; } from '../../../hooks/usePanelStore';
import { useSpecStore } from '../../../hooks/useSpecStore'; import { useSpecStore } from '../../../hooks/useSpecStore';
@@ -63,6 +51,22 @@ interface ExpressionsResult {
user_count: number; user_count: number;
} }
interface IntrospectionResult {
solver_type?: string;
expressions?: ExpressionsResult;
// Allow other properties from the API response
file_deps?: unknown[];
fea_results?: unknown[];
fem_mesh?: unknown;
sim_solutions?: unknown[];
sim_bcs?: unknown[];
mass_properties?: {
total_mass?: number;
center_of_gravity?: { x: number; y: number; z: number };
[key: string]: unknown;
};
}
interface ModelFileInfo { interface ModelFileInfo {
name: string; name: string;
stem: string; stem: string;
@@ -103,9 +107,9 @@ export function FloatingIntrospectionPanel({ onClose }: FloatingIntrospectionPan
const [isLoadingFiles, setIsLoadingFiles] = useState(false); const [isLoadingFiles, setIsLoadingFiles] = useState(false);
const data = panel.data; const data = panel.data;
const result = data?.result; const result = data?.result as IntrospectionResult | undefined;
const isLoading = data?.isLoading || false; const isLoading = data?.isLoading || false;
const error = data?.error; const error = data?.error as string | null;
// Fetch available files when studyId changes // Fetch available files when studyId changes
const fetchAvailableFiles = useCallback(async () => { const fetchAvailableFiles = useCallback(async () => {
@@ -209,7 +213,7 @@ export function FloatingIntrospectionPanel({ onClose }: FloatingIntrospectionPan
const minValue = expr.min ?? expr.value * 0.5; const minValue = expr.min ?? expr.value * 0.5;
const maxValue = expr.max ?? expr.value * 1.5; const maxValue = expr.max ?? expr.value * 1.5;
addNode('dv', { addNode('designVar', {
name: expr.name, name: expr.name,
expression_name: expr.name, expression_name: expr.name,
type: 'continuous', type: 'continuous',

View File

@@ -43,7 +43,6 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
const { updateNode, removeNode, clearSelection } = useSpecStore(); const { updateNode, removeNode, clearSelection } = useSpecStore();
const [showFileBrowser, setShowFileBrowser] = useState(false); const [showFileBrowser, setShowFileBrowser] = useState(false);
const [showIntrospection, setShowIntrospection] = useState(false);
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -249,16 +248,7 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
fileTypes={['.sim', '.prt', '.fem', '.afem']} fileTypes={['.sim', '.prt', '.fem', '.afem']}
/> />
{/* Introspection Panel */} {/* Introspection is now handled by FloatingIntrospectionPanel via usePanelStore */}
{showIntrospection && spec.model.sim?.path && (
<div className="fixed top-20 right-96 z-40">
<IntrospectionPanel
filePath={spec.model.sim.path}
studyId={useSpecStore.getState().studyId || undefined}
onClose={() => setShowIntrospection(false)}
/>
</div>
)}
</div> </div>
); );
} }
@@ -280,6 +270,7 @@ function ModelNodeConfig({ spec }: SpecConfigProps) {
filePath: spec.model.sim?.path || '', filePath: spec.model.sim?.path || '',
studyId: useSpecStore.getState().studyId || undefined, studyId: useSpecStore.getState().studyId || undefined,
}); });
openPanel('introspection');
}; };
return ( return (

View File

@@ -73,7 +73,7 @@ interface FloatingValidationPanelProps {
export function FloatingValidationPanel({ onClose }: FloatingValidationPanelProps) { export function FloatingValidationPanel({ onClose }: FloatingValidationPanelProps) {
const panel = useValidationPanel(); const panel = useValidationPanel();
const { minimizePanel } = usePanelStore(); const { minimizePanel } = usePanelStore();
const { setSelectedNodeId } = useSpecStore(); const { selectNode } = useSpecStore();
const { errors, warnings, valid } = useMemo(() => { const { errors, warnings, valid } = useMemo(() => {
if (!panel.data) { if (!panel.data) {
@@ -88,7 +88,7 @@ export function FloatingValidationPanel({ onClose }: FloatingValidationPanelProp
const handleNavigateToNode = (nodeId?: string) => { const handleNavigateToNode = (nodeId?: string) => {
if (nodeId) { if (nodeId) {
setSelectedNodeId(nodeId); selectNode(nodeId);
} }
}; };

View File

@@ -0,0 +1,240 @@
/**
* ConvergenceSparkline - Tiny SVG chart showing optimization convergence
*
* Displays the last N trial values as a mini line chart.
* Used on ObjectiveNode to show convergence trend.
*/
import { useMemo } from 'react';
interface ConvergenceSparklineProps {
/** Array of values (most recent last) */
values: number[];
/** Width in pixels */
width?: number;
/** Height in pixels */
height?: number;
/** Line color */
color?: string;
/** Best value line color */
bestColor?: string;
/** Whether to show the best value line */
showBest?: boolean;
/** Direction: minimize shows lower as better, maximize shows higher as better */
direction?: 'minimize' | 'maximize';
/** Show dots at each point */
showDots?: boolean;
/** Number of points to display */
maxPoints?: number;
}
export function ConvergenceSparkline({
values,
width = 80,
height = 24,
color = '#60a5fa',
bestColor = '#34d399',
showBest = true,
direction = 'minimize',
showDots = false,
maxPoints = 20,
}: ConvergenceSparklineProps) {
const { path, bestY, points } = useMemo(() => {
if (!values || values.length === 0) {
return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 };
}
// Take last N points
const data = values.slice(-maxPoints);
if (data.length === 0) {
return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 };
}
// Calculate bounds with padding
const minVal = Math.min(...data);
const maxVal = Math.max(...data);
const range = maxVal - minVal || 1;
const padding = range * 0.1;
const yMin = minVal - padding;
const yMax = maxVal + padding;
const yRange = yMax - yMin;
// Calculate best value
const bestVal = direction === 'minimize' ? Math.min(...data) : Math.max(...data);
// Map values to SVG coordinates
const xStep = width / Math.max(data.length - 1, 1);
const mapY = (v: number) => height - ((v - yMin) / yRange) * height;
// Build path
const points = data.map((v, i) => ({
x: i * xStep,
y: mapY(v),
value: v,
}));
const pathParts = points.map((p, i) =>
i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`
);
return {
path: pathParts.join(' '),
bestY: mapY(bestVal),
points,
minVal,
maxVal,
};
}, [values, width, height, maxPoints, direction]);
if (!values || values.length === 0) {
return (
<div
className="flex items-center justify-center text-dark-500 text-xs"
style={{ width, height }}
>
No data
</div>
);
}
return (
<svg
width={width}
height={height}
className="overflow-visible"
viewBox={`0 0 ${width} ${height}`}
>
{/* Best value line */}
{showBest && bestY !== null && (
<line
x1={0}
y1={bestY}
x2={width}
y2={bestY}
stroke={bestColor}
strokeWidth={1}
strokeDasharray="2,2"
opacity={0.5}
/>
)}
{/* Main line */}
<path
d={path}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Gradient fill under the line */}
<defs>
<linearGradient id="sparkline-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
{points.length > 1 && (
<path
d={`${path} L ${points[points.length - 1].x} ${height} L ${points[0].x} ${height} Z`}
fill="url(#sparkline-gradient)"
/>
)}
{/* Dots at each point */}
{showDots && points.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r={2}
fill={color}
/>
))}
{/* Last point highlight */}
{points.length > 0 && (
<circle
cx={points[points.length - 1].x}
cy={points[points.length - 1].y}
r={3}
fill={color}
stroke="white"
strokeWidth={1}
/>
)}
</svg>
);
}
/**
* ProgressRing - Circular progress indicator
*/
interface ProgressRingProps {
/** Progress percentage (0-100) */
progress: number;
/** Size in pixels */
size?: number;
/** Stroke width */
strokeWidth?: number;
/** Progress color */
color?: string;
/** Background color */
bgColor?: string;
/** Show percentage text */
showText?: boolean;
}
export function ProgressRing({
progress,
size = 32,
strokeWidth = 3,
color = '#60a5fa',
bgColor = '#374151',
showText = true,
}: ProgressRingProps) {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (Math.min(100, Math.max(0, progress)) / 100) * circumference;
return (
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="transform -rotate-90">
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={bgColor}
strokeWidth={strokeWidth}
/>
{/* Progress circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-300"
/>
</svg>
{showText && (
<span
className="absolute text-xs font-medium"
style={{ color, fontSize: size * 0.25 }}
>
{Math.round(progress)}%
</span>
)}
</div>
);
}
export default ConvergenceSparkline;

View File

@@ -0,0 +1,335 @@
/**
* useOptimizationStream - Enhanced WebSocket hook for real-time optimization updates
*
* This hook provides:
* - Real-time trial updates (no polling needed)
* - Best trial tracking
* - Progress tracking
* - Error detection and reporting
* - Integration with panel store for error display
* - Automatic reconnection
*
* Usage:
* ```tsx
* const {
* isConnected,
* progress,
* bestTrial,
* recentTrials,
* status
* } = useOptimizationStream(studyId);
* ```
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import useWebSocket, { ReadyState } from 'react-use-websocket';
import { usePanelStore } from './usePanelStore';
// ============================================================================
// Types
// ============================================================================
export interface TrialData {
trial_number: number;
trial_num: number;
objective: number | null;
values: number[];
params: Record<string, number>;
user_attrs: Record<string, unknown>;
source: 'FEA' | 'NN' | string;
start_time: string;
end_time: string;
study_name: string;
constraint_satisfied: boolean;
}
export interface ProgressData {
current: number;
total: number;
percentage: number;
fea_count: number;
nn_count: number;
timestamp: string;
}
export interface BestTrialData {
trial_number: number;
value: number;
params: Record<string, number>;
improvement: number;
}
export interface ParetoData {
pareto_front: Array<{
trial_number: number;
values: number[];
params: Record<string, number>;
constraint_satisfied: boolean;
source: string;
}>;
count: number;
}
export type OptimizationStatus = 'disconnected' | 'connecting' | 'connected' | 'running' | 'paused' | 'completed' | 'failed';
export interface OptimizationStreamState {
isConnected: boolean;
status: OptimizationStatus;
progress: ProgressData | null;
bestTrial: BestTrialData | null;
recentTrials: TrialData[];
paretoFront: ParetoData | null;
lastUpdate: number | null;
error: string | null;
}
// ============================================================================
// Hook
// ============================================================================
interface UseOptimizationStreamOptions {
/** Maximum number of recent trials to keep */
maxRecentTrials?: number;
/** Callback when a new trial completes */
onTrialComplete?: (trial: TrialData) => void;
/** Callback when a new best is found */
onNewBest?: (best: BestTrialData) => void;
/** Callback on progress update */
onProgress?: (progress: ProgressData) => void;
/** Whether to auto-report errors to the error panel */
autoReportErrors?: boolean;
}
export function useOptimizationStream(
studyId: string | null | undefined,
options: UseOptimizationStreamOptions = {}
) {
const {
maxRecentTrials = 20,
onTrialComplete,
onNewBest,
onProgress,
autoReportErrors = true,
} = options;
// Panel store for error reporting
const { addError } = usePanelStore();
// State
const [state, setState] = useState<OptimizationStreamState>({
isConnected: false,
status: 'disconnected',
progress: null,
bestTrial: null,
recentTrials: [],
paretoFront: null,
lastUpdate: null,
error: null,
});
// Track last error timestamp to avoid duplicates
const lastErrorTime = useRef<number>(0);
// Build WebSocket URL
const socketUrl = studyId
? `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${
import.meta.env.DEV ? 'localhost:8001' : window.location.host
}/api/ws/optimization/${encodeURIComponent(studyId)}`
: null;
// WebSocket connection
const { sendMessage, lastMessage, readyState } = useWebSocket(socketUrl, {
shouldReconnect: () => true,
reconnectAttempts: 10,
reconnectInterval: 3000,
onOpen: () => {
console.log('[OptStream] Connected to optimization stream');
setState(prev => ({ ...prev, isConnected: true, status: 'connected', error: null }));
},
onClose: () => {
console.log('[OptStream] Disconnected from optimization stream');
setState(prev => ({ ...prev, isConnected: false, status: 'disconnected' }));
},
onError: (event) => {
console.error('[OptStream] WebSocket error:', event);
setState(prev => ({ ...prev, error: 'WebSocket connection error' }));
},
});
// Update connection status
useEffect(() => {
const statusMap: Record<ReadyState, OptimizationStatus> = {
[ReadyState.CONNECTING]: 'connecting',
[ReadyState.OPEN]: 'connected',
[ReadyState.CLOSING]: 'disconnected',
[ReadyState.CLOSED]: 'disconnected',
[ReadyState.UNINSTANTIATED]: 'disconnected',
};
setState(prev => ({
...prev,
isConnected: readyState === ReadyState.OPEN,
status: prev.status === 'running' || prev.status === 'completed' || prev.status === 'failed'
? prev.status
: statusMap[readyState] || 'disconnected',
}));
}, [readyState]);
// Process incoming messages
useEffect(() => {
if (!lastMessage?.data) return;
try {
const message = JSON.parse(lastMessage.data);
const { type, data } = message;
switch (type) {
case 'connected':
console.log('[OptStream] Connection confirmed:', data.message);
break;
case 'trial_completed':
handleTrialComplete(data as TrialData);
break;
case 'new_best':
handleNewBest(data as BestTrialData);
break;
case 'progress':
handleProgress(data as ProgressData);
break;
case 'pareto_update':
handleParetoUpdate(data as ParetoData);
break;
case 'heartbeat':
case 'pong':
// Keep-alive messages
break;
case 'error':
handleError(data);
break;
default:
console.log('[OptStream] Unknown message type:', type, data);
}
} catch (e) {
console.error('[OptStream] Failed to parse message:', e);
}
}, [lastMessage]);
// Handler functions
const handleTrialComplete = useCallback((trial: TrialData) => {
setState(prev => {
const newTrials = [trial, ...prev.recentTrials].slice(0, maxRecentTrials);
return {
...prev,
recentTrials: newTrials,
lastUpdate: Date.now(),
status: 'running',
};
});
onTrialComplete?.(trial);
}, [maxRecentTrials, onTrialComplete]);
const handleNewBest = useCallback((best: BestTrialData) => {
setState(prev => ({
...prev,
bestTrial: best,
lastUpdate: Date.now(),
}));
onNewBest?.(best);
}, [onNewBest]);
const handleProgress = useCallback((progress: ProgressData) => {
setState(prev => {
// Determine status based on progress
let status: OptimizationStatus = prev.status;
if (progress.current > 0 && progress.current < progress.total) {
status = 'running';
} else if (progress.current >= progress.total) {
status = 'completed';
}
return {
...prev,
progress,
status,
lastUpdate: Date.now(),
};
});
onProgress?.(progress);
}, [onProgress]);
const handleParetoUpdate = useCallback((pareto: ParetoData) => {
setState(prev => ({
...prev,
paretoFront: pareto,
lastUpdate: Date.now(),
}));
}, []);
const handleError = useCallback((errorData: { message: string; details?: string; trial?: number }) => {
const now = Date.now();
// Avoid duplicate errors within 5 seconds
if (now - lastErrorTime.current < 5000) return;
lastErrorTime.current = now;
setState(prev => ({
...prev,
error: errorData.message,
status: 'failed',
}));
if (autoReportErrors) {
addError({
type: 'system_error',
message: errorData.message,
details: errorData.details,
trial: errorData.trial,
recoverable: true,
suggestions: ['Check the optimization logs', 'Try restarting the optimization'],
timestamp: now,
});
}
}, [autoReportErrors, addError]);
// Send ping to keep connection alive
useEffect(() => {
if (readyState !== ReadyState.OPEN) return;
const interval = setInterval(() => {
sendMessage(JSON.stringify({ type: 'ping' }));
}, 25000); // Ping every 25 seconds
return () => clearInterval(interval);
}, [readyState, sendMessage]);
// Reset state when study changes
useEffect(() => {
setState({
isConnected: false,
status: 'disconnected',
progress: null,
bestTrial: null,
recentTrials: [],
paretoFront: null,
lastUpdate: null,
error: null,
});
}, [studyId]);
return {
...state,
sendPing: () => sendMessage(JSON.stringify({ type: 'ping' })),
};
}
export default useOptimizationStream;

View File

@@ -99,6 +99,7 @@ export interface ObjectiveNodeData extends BaseNodeData {
extractorRef?: string; // Reference to extractor ID extractorRef?: string; // Reference to extractor ID
outputName?: string; // Which output from the extractor outputName?: string; // Which output from the extractor
penaltyWeight?: number; // For hard constraints (penalty method) penaltyWeight?: number; // For hard constraints (penalty method)
history?: number[]; // Recent values for sparkline visualization
} }
export interface ConstraintNodeData extends BaseNodeData { export interface ConstraintNodeData extends BaseNodeData {

View File

@@ -5,7 +5,7 @@
* returning structured errors that can be displayed in the ValidationPanel. * returning structured errors that can be displayed in the ValidationPanel.
*/ */
import { AtomizerSpec, DesignVariable, Extractor, Objective, Constraint } from '../../types/atomizer-spec'; import { AtomizerSpec } from '../../types/atomizer-spec';
import { ValidationError, ValidationData } from '../../hooks/usePanelStore'; import { ValidationError, ValidationData } from '../../hooks/usePanelStore';
// ============================================================================ // ============================================================================
@@ -178,9 +178,9 @@ const validationRules: ValidationRule[] = [
edge => edge.target === obj.id && edge.source.startsWith('ext_') edge => edge.target === obj.id && edge.source.startsWith('ext_')
); );
// Also check if source_extractor_id is set // Also check if source.extractor_id is set
const hasDirectSource = obj.source_extractor_id && const hasDirectSource = obj.source?.extractor_id &&
spec.extractors.some(e => e.id === obj.source_extractor_id); spec.extractors.some(e => e.id === obj.source.extractor_id);
if (!hasSource && !hasDirectSource) { if (!hasSource && !hasDirectSource) {
return { return {

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight, Save, RefreshCw, Zap, MessageSquare, X, Folder, SlidersHorizontal, Undo2, Redo2, CheckCircle } from 'lucide-react'; import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight, Save, RefreshCw, Zap, MessageSquare, X, Folder, SlidersHorizontal, Undo2, Redo2 } from 'lucide-react';
import { AtomizerCanvas } from '../components/canvas/AtomizerCanvas'; import { AtomizerCanvas } from '../components/canvas/AtomizerCanvas';
import { SpecRenderer } from '../components/canvas/SpecRenderer'; import { SpecRenderer } from '../components/canvas/SpecRenderer';
import { NodePalette } from '../components/canvas/palette/NodePalette'; import { NodePalette } from '../components/canvas/palette/NodePalette';
@@ -13,7 +13,7 @@ import { ChatPanel } from '../components/canvas/panels/ChatPanel';
import { PanelContainer } from '../components/canvas/panels/PanelContainer'; import { PanelContainer } from '../components/canvas/panels/PanelContainer';
import { useCanvasStore } from '../hooks/useCanvasStore'; import { useCanvasStore } from '../hooks/useCanvasStore';
import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId } from '../hooks/useSpecStore'; import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId } from '../hooks/useSpecStore';
import { usePanelStore } from '../hooks/usePanelStore'; // usePanelStore is now used by child components - PanelContainer handles panels
import { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo'; import { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo';
import { useStudy } from '../context/StudyContext'; import { useStudy } from '../context/StudyContext';
import { useChat } from '../hooks/useChat'; import { useChat } from '../hooks/useChat';