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:
@@ -39,7 +39,9 @@ import {
|
||||
} from '../../hooks/useSpecStore';
|
||||
import { useSpecWebSocket } from '../../hooks/useSpecWebSocket';
|
||||
import { usePanelStore } from '../../hooks/usePanelStore';
|
||||
import { useOptimizationStream } from '../../hooks/useOptimizationStream';
|
||||
import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
|
||||
import { ProgressRing } from './visualization/ConvergenceSparkline';
|
||||
import { CanvasNodeData } from '../../lib/canvas/schema';
|
||||
import { validateSpec, canRunOptimization } from '../../lib/validation/specValidator';
|
||||
|
||||
@@ -207,107 +209,66 @@ function SpecRendererInner({
|
||||
// Panel store for validation and error panels
|
||||
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
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const isRunning = optimizationStatus === 'running';
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [bestTrial, setBestTrial] = useState<any>(null);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked');
|
||||
|
||||
// Track last seen error timestamp to avoid duplicates
|
||||
const lastErrorTimestamp = useRef<number>(0);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Build trial history for sparklines (extract objective values from recent trials)
|
||||
const trialHistory = useMemo(() => {
|
||||
const history: Record<string, number[]> = {};
|
||||
for (const trial of recentTrials) {
|
||||
// Map objective values - assumes single objective for now
|
||||
if (trial.objective !== null) {
|
||||
const key = 'primary';
|
||||
if (!history[key]) history[key] = [];
|
||||
history[key].push(trial.objective);
|
||||
}
|
||||
// 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 },
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
const interval = setInterval(checkStatus, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [studyId, addError]);
|
||||
}, [wsBestTrial]);
|
||||
|
||||
// Note: Polling removed - now using WebSocket via useOptimizationStream hook
|
||||
// The hook handles: status updates, best trial updates, error reporting
|
||||
|
||||
// Validate the spec and show results in panel
|
||||
const handleValidate = useCallback(() => {
|
||||
@@ -359,7 +320,7 @@ function SpecRendererInner({
|
||||
const err = await res.json();
|
||||
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
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Failed to start optimization';
|
||||
@@ -386,7 +347,7 @@ function SpecRendererInner({
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Failed to stop');
|
||||
}
|
||||
setIsRunning(false);
|
||||
// isRunning will update via WebSocket when optimization actually stops
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Failed to stop optimization';
|
||||
setError(errorMessage);
|
||||
@@ -415,47 +376,56 @@ function SpecRendererInner({
|
||||
const nodes = useMemo(() => {
|
||||
const baseNodes = specToNodes(spec);
|
||||
|
||||
if (showResults && bestTrial) {
|
||||
return baseNodes.map(node => {
|
||||
const newData = { ...node.data };
|
||||
|
||||
// Map results to nodes
|
||||
if (node.type === 'designVar') {
|
||||
// Always map nodes to include history for sparklines (even if not showing results)
|
||||
return baseNodes.map(node => {
|
||||
// Create a mutable copy with explicit any type for dynamic property assignment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const newData: any = { ...node.data };
|
||||
|
||||
// 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];
|
||||
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];
|
||||
}
|
||||
const outputName = newData.outputName;
|
||||
if (outputName && 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;
|
||||
}
|
||||
const outputName = newData.outputName;
|
||||
if (outputName && bestTrial.results?.[outputName] !== undefined) {
|
||||
const val = bestTrial.results[outputName];
|
||||
newData.resultValue = val;
|
||||
|
||||
// Check feasibility
|
||||
const op = newData.operator;
|
||||
const threshold = newData.value;
|
||||
if (op === '<=' && threshold !== undefined) newData.isFeasible = val <= threshold;
|
||||
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 = val > threshold;
|
||||
else if (op === '==' && threshold !== undefined) newData.isFeasible = Math.abs(val - threshold) < 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];
|
||||
}
|
||||
}
|
||||
const outputNames = newData.outputNames;
|
||||
if (outputNames && outputNames.length > 0 && bestTrial.results) {
|
||||
const firstOut = outputNames[0];
|
||||
if (bestTrial.results[firstOut] !== undefined) {
|
||||
newData.resultValue = bestTrial.results[firstOut];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...node, data: newData };
|
||||
});
|
||||
}
|
||||
|
||||
return baseNodes;
|
||||
}, [spec, showResults, bestTrial]);
|
||||
}
|
||||
|
||||
return { ...node, data: newData };
|
||||
});
|
||||
}, [spec, showResults, bestTrial, trialHistory]);
|
||||
|
||||
// Convert spec to ReactFlow edges with selection styling
|
||||
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">
|
||||
<span className="text-sm text-dark-300">{spec.meta.study_name}</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,37 @@ import { NodeProps } from 'reactflow';
|
||||
import { Target } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { ResultBadge } from './ResultBadge';
|
||||
import { ConvergenceSparkline } from '../visualization/ConvergenceSparkline';
|
||||
import { ObjectiveNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) {
|
||||
const { data } = props;
|
||||
const hasHistory = data.history && data.history.length > 1;
|
||||
|
||||
return (
|
||||
<BaseNode {...props} icon={<Target size={16} />} iconColor="text-rose-400">
|
||||
<ResultBadge value={data.resultValue} label="Best" />
|
||||
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
|
||||
<div className="flex flex-col gap-1">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,27 +15,15 @@ import {
|
||||
Plus,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileBox,
|
||||
Cpu,
|
||||
SlidersHorizontal,
|
||||
AlertTriangle,
|
||||
Scale,
|
||||
Link,
|
||||
Box,
|
||||
Settings2,
|
||||
GitBranch,
|
||||
File,
|
||||
Grid3x3,
|
||||
Target,
|
||||
Zap,
|
||||
Layers,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useIntrospectionPanel,
|
||||
usePanelStore,
|
||||
IntrospectionData,
|
||||
} from '../../../hooks/usePanelStore';
|
||||
import { useSpecStore } from '../../../hooks/useSpecStore';
|
||||
|
||||
@@ -63,6 +51,22 @@ interface ExpressionsResult {
|
||||
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 {
|
||||
name: string;
|
||||
stem: string;
|
||||
@@ -103,9 +107,9 @@ export function FloatingIntrospectionPanel({ onClose }: FloatingIntrospectionPan
|
||||
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
||||
|
||||
const data = panel.data;
|
||||
const result = data?.result;
|
||||
const result = data?.result as IntrospectionResult | undefined;
|
||||
const isLoading = data?.isLoading || false;
|
||||
const error = data?.error;
|
||||
const error = data?.error as string | null;
|
||||
|
||||
// Fetch available files when studyId changes
|
||||
const fetchAvailableFiles = useCallback(async () => {
|
||||
@@ -209,7 +213,7 @@ export function FloatingIntrospectionPanel({ onClose }: FloatingIntrospectionPan
|
||||
const minValue = expr.min ?? expr.value * 0.5;
|
||||
const maxValue = expr.max ?? expr.value * 1.5;
|
||||
|
||||
addNode('dv', {
|
||||
addNode('designVar', {
|
||||
name: expr.name,
|
||||
expression_name: expr.name,
|
||||
type: 'continuous',
|
||||
|
||||
@@ -43,7 +43,6 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
|
||||
const { updateNode, removeNode, clearSelection } = useSpecStore();
|
||||
|
||||
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||
const [showIntrospection, setShowIntrospection] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -249,16 +248,7 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
|
||||
fileTypes={['.sim', '.prt', '.fem', '.afem']}
|
||||
/>
|
||||
|
||||
{/* Introspection Panel */}
|
||||
{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>
|
||||
)}
|
||||
{/* Introspection is now handled by FloatingIntrospectionPanel via usePanelStore */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -280,6 +270,7 @@ function ModelNodeConfig({ spec }: SpecConfigProps) {
|
||||
filePath: spec.model.sim?.path || '',
|
||||
studyId: useSpecStore.getState().studyId || undefined,
|
||||
});
|
||||
openPanel('introspection');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -73,7 +73,7 @@ interface FloatingValidationPanelProps {
|
||||
export function FloatingValidationPanel({ onClose }: FloatingValidationPanelProps) {
|
||||
const panel = useValidationPanel();
|
||||
const { minimizePanel } = usePanelStore();
|
||||
const { setSelectedNodeId } = useSpecStore();
|
||||
const { selectNode } = useSpecStore();
|
||||
|
||||
const { errors, warnings, valid } = useMemo(() => {
|
||||
if (!panel.data) {
|
||||
@@ -88,7 +88,7 @@ export function FloatingValidationPanel({ onClose }: FloatingValidationPanelProp
|
||||
|
||||
const handleNavigateToNode = (nodeId?: string) => {
|
||||
if (nodeId) {
|
||||
setSelectedNodeId(nodeId);
|
||||
selectNode(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
335
atomizer-dashboard/frontend/src/hooks/useOptimizationStream.ts
Normal file
335
atomizer-dashboard/frontend/src/hooks/useOptimizationStream.ts
Normal 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;
|
||||
@@ -99,6 +99,7 @@ export interface ObjectiveNodeData extends BaseNodeData {
|
||||
extractorRef?: string; // Reference to extractor ID
|
||||
outputName?: string; // Which output from the extractor
|
||||
penaltyWeight?: number; // For hard constraints (penalty method)
|
||||
history?: number[]; // Recent values for sparkline visualization
|
||||
}
|
||||
|
||||
export interface ConstraintNodeData extends BaseNodeData {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 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';
|
||||
|
||||
// ============================================================================
|
||||
@@ -178,9 +178,9 @@ const validationRules: ValidationRule[] = [
|
||||
edge => edge.target === obj.id && edge.source.startsWith('ext_')
|
||||
);
|
||||
|
||||
// Also check if source_extractor_id is set
|
||||
const hasDirectSource = obj.source_extractor_id &&
|
||||
spec.extractors.some(e => e.id === obj.source_extractor_id);
|
||||
// Also check if source.extractor_id is set
|
||||
const hasDirectSource = obj.source?.extractor_id &&
|
||||
spec.extractors.some(e => e.id === obj.source.extractor_id);
|
||||
|
||||
if (!hasSource && !hasDirectSource) {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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 { SpecRenderer } from '../components/canvas/SpecRenderer';
|
||||
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 { useCanvasStore } from '../hooks/useCanvasStore';
|
||||
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 { useStudy } from '../context/StudyContext';
|
||||
import { useChat } from '../hooks/useChat';
|
||||
|
||||
Reference in New Issue
Block a user