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';
|
} 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user