feat: Add dashboard chat integration and MCP server
Major changes: - Dashboard: WebSocket-based chat with session management - Dashboard: New chat components (ChatPane, ChatInput, ModeToggle) - Dashboard: Enhanced UI with parallel coordinates chart - MCP Server: New atomizer-tools server for Claude integration - Extractors: Enhanced Zernike OPD extractor - Reports: Improved report generator New studies (configs and scripts only): - M1 Mirror: Cost reduction campaign studies - Simple Beam, Simple Bracket, UAV Arm studies Note: Large iteration data (2_iterations/, best_design_archive/) excluded via .gitignore - kept on local Gitea only. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
CheckCircle,
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
Sliders,
|
||||
Skull
|
||||
Skull,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { apiClient, ProcessStatus } from '../../api/client';
|
||||
import { useStudy } from '../../context/StudyContext';
|
||||
@@ -23,11 +25,14 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, hori
|
||||
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [optunaAvailable, setOptunaAvailable] = useState<boolean | null>(null);
|
||||
const [optunaInstallHint, setOptunaInstallHint] = useState<string | null>(null);
|
||||
|
||||
// Settings for starting optimization
|
||||
const [settings, setSettings] = useState({
|
||||
freshStart: false,
|
||||
maxIterations: 100,
|
||||
trials: 300, // For SAT scripts
|
||||
feaBatchSize: 5,
|
||||
tuneTrials: 30,
|
||||
ensembleSize: 3,
|
||||
@@ -45,6 +50,23 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, hori
|
||||
}
|
||||
}, [selectedStudy]);
|
||||
|
||||
// Check optuna-dashboard availability on mount
|
||||
useEffect(() => {
|
||||
const checkOptuna = async () => {
|
||||
try {
|
||||
const result = await apiClient.checkOptunaAvailable();
|
||||
setOptunaAvailable(result.available);
|
||||
if (!result.available && result.install_instructions) {
|
||||
setOptunaInstallHint(result.install_instructions);
|
||||
}
|
||||
} catch {
|
||||
setOptunaAvailable(false);
|
||||
setOptunaInstallHint('pip install optuna-dashboard');
|
||||
}
|
||||
};
|
||||
checkOptuna();
|
||||
}, []);
|
||||
|
||||
const fetchProcessStatus = async () => {
|
||||
if (!selectedStudy) return;
|
||||
try {
|
||||
@@ -65,6 +87,7 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, hori
|
||||
await apiClient.startOptimization(selectedStudy.id, {
|
||||
freshStart: settings.freshStart,
|
||||
maxIterations: settings.maxIterations,
|
||||
trials: settings.trials,
|
||||
feaBatchSize: settings.feaBatchSize,
|
||||
tuneTrials: settings.tuneTrials,
|
||||
ensembleSize: settings.ensembleSize,
|
||||
@@ -98,6 +121,38 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, hori
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
if (!selectedStudy) return;
|
||||
setActionInProgress('pause');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await apiClient.pauseOptimization(selectedStudy.id);
|
||||
await fetchProcessStatus();
|
||||
onStatusChange?.();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to pause optimization');
|
||||
} finally {
|
||||
setActionInProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async () => {
|
||||
if (!selectedStudy) return;
|
||||
setActionInProgress('resume');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await apiClient.resumeOptimization(selectedStudy.id);
|
||||
await fetchProcessStatus();
|
||||
onStatusChange?.();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to resume optimization');
|
||||
} finally {
|
||||
setActionInProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (!selectedStudy) return;
|
||||
setActionInProgress('validate');
|
||||
@@ -131,6 +186,7 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, hori
|
||||
};
|
||||
|
||||
const isRunning = processStatus?.is_running || selectedStudy?.status === 'running';
|
||||
const isPaused = processStatus?.is_paused || false;
|
||||
|
||||
// Horizontal layout for top of page
|
||||
if (horizontal) {
|
||||
@@ -140,7 +196,12 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, hori
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isRunning ? (
|
||||
{isRunning && isPaused ? (
|
||||
<>
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
|
||||
<span className="text-yellow-400 font-medium text-sm">Paused</span>
|
||||
</>
|
||||
) : isRunning ? (
|
||||
<>
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse" />
|
||||
<span className="text-green-400 font-medium text-sm">Running</span>
|
||||
@@ -152,28 +213,65 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, hori
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{processStatus?.fea_count && (
|
||||
{/* Trial counts */}
|
||||
{processStatus?.completed_trials !== undefined && (
|
||||
<span className="text-xs text-dark-400">
|
||||
FEA: <span className="text-primary-400">{processStatus.fea_count}</span>
|
||||
{processStatus.nn_count && (
|
||||
<> | NN: <span className="text-orange-400">{processStatus.nn_count}</span></>
|
||||
Trials: <span className="text-white font-mono">{processStatus.completed_trials}</span>
|
||||
{processStatus.total_trials && (
|
||||
<span className="text-dark-500">/{processStatus.total_trials}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* ETA and Rate */}
|
||||
{isRunning && processStatus?.eta_formatted && (
|
||||
<span className="text-xs text-dark-400">
|
||||
ETA: <span className="text-primary-400 font-mono">{processStatus.eta_formatted}</span>
|
||||
</span>
|
||||
)}
|
||||
{processStatus?.rate_per_hour && (
|
||||
<span className="text-xs text-dark-400">
|
||||
Rate: <span className="text-dark-300 font-mono">{processStatus.rate_per_hour.toFixed(1)}/hr</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isRunning ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={actionInProgress !== null}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-red-600 hover:bg-red-500
|
||||
disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{actionInProgress === 'stop' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Skull className="w-4 h-4" />}
|
||||
Kill
|
||||
</button>
|
||||
<>
|
||||
{/* Pause/Resume Button */}
|
||||
{isPaused ? (
|
||||
<button
|
||||
onClick={handleResume}
|
||||
disabled={actionInProgress !== null}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-green-600 hover:bg-green-500
|
||||
disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{actionInProgress === 'resume' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
Resume
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handlePause}
|
||||
disabled={actionInProgress !== null}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-yellow-600 hover:bg-yellow-500
|
||||
disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{actionInProgress === 'pause' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Pause className="w-4 h-4" />}
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
{/* Kill Button */}
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={actionInProgress !== null}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-red-600 hover:bg-red-500
|
||||
disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{actionInProgress === 'stop' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Skull className="w-4 h-4" />}
|
||||
Kill
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
@@ -196,15 +294,26 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, hori
|
||||
Validate
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLaunchOptuna}
|
||||
disabled={actionInProgress !== null}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 hover:bg-dark-600
|
||||
border border-dark-600 disabled:opacity-50 text-dark-300 hover:text-white rounded-lg text-sm"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Optuna
|
||||
</button>
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={handleLaunchOptuna}
|
||||
disabled={actionInProgress !== null || optunaAvailable === false}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 bg-dark-700 hover:bg-dark-600
|
||||
border border-dark-600 disabled:opacity-50 text-dark-300 hover:text-white rounded-lg text-sm
|
||||
${optunaAvailable === false ? 'cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Optuna
|
||||
</button>
|
||||
{optunaAvailable === false && optunaInstallHint && (
|
||||
<div className="absolute bottom-full left-0 mb-2 px-2 py-1 bg-dark-900 border border-dark-600
|
||||
rounded text-xs text-dark-400 whitespace-nowrap opacity-0 group-hover:opacity-100
|
||||
transition-opacity pointer-events-none z-10">
|
||||
<Info className="w-3 h-3 inline mr-1" />
|
||||
Install with: {optunaInstallHint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Toggle */}
|
||||
@@ -231,11 +340,11 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, hori
|
||||
<div className="px-4 py-3 border-t border-dark-700 bg-dark-750">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-dark-400">Iterations:</label>
|
||||
<label className="text-xs text-dark-400">Trials (SAT):</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.maxIterations}
|
||||
onChange={(e) => setSettings({ ...settings, maxIterations: parseInt(e.target.value) || 100 })}
|
||||
value={settings.trials}
|
||||
onChange={(e) => setSettings({ ...settings, trials: parseInt(e.target.value) || 300 })}
|
||||
className="w-16 px-2 py-1 bg-dark-700 border border-dark-600 rounded text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -327,7 +436,12 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, hori
|
||||
<div>
|
||||
<div className="text-sm text-dark-400 mb-1">Status</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isRunning ? (
|
||||
{isRunning && isPaused ? (
|
||||
<>
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
|
||||
<span className="text-yellow-400 font-medium">Paused</span>
|
||||
</>
|
||||
) : isRunning ? (
|
||||
<>
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse" />
|
||||
<span className="text-green-400 font-medium">Running</span>
|
||||
@@ -343,17 +457,22 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, hori
|
||||
|
||||
{processStatus && (
|
||||
<div className="text-right">
|
||||
{processStatus.iteration && (
|
||||
{processStatus.completed_trials !== undefined && (
|
||||
<div className="text-sm text-dark-400">
|
||||
Iteration: <span className="text-white">{processStatus.iteration}</span>
|
||||
Trials: <span className="text-white font-mono">{processStatus.completed_trials}</span>
|
||||
{processStatus.total_trials && (
|
||||
<span className="text-dark-500">/{processStatus.total_trials}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{processStatus.fea_count && (
|
||||
{isRunning && !isPaused && processStatus.eta_formatted && (
|
||||
<div className="text-sm text-dark-400">
|
||||
FEA: <span className="text-primary-400">{processStatus.fea_count}</span>
|
||||
{processStatus.nn_count && (
|
||||
<> | NN: <span className="text-orange-400">{processStatus.nn_count}</span></>
|
||||
)}
|
||||
ETA: <span className="text-primary-400 font-mono">{processStatus.eta_formatted}</span>
|
||||
</div>
|
||||
)}
|
||||
{processStatus.rate_per_hour && (
|
||||
<div className="text-sm text-dark-400">
|
||||
Rate: <span className="text-dark-300 font-mono">{processStatus.rate_per_hour.toFixed(1)}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -366,11 +485,11 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, hori
|
||||
<div className="px-6 py-4 border-b border-dark-700 bg-dark-750">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-dark-400 mb-1">Max Iterations</label>
|
||||
<label className="block text-xs text-dark-400 mb-1">Trials (SAT)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.maxIterations}
|
||||
onChange={(e) => setSettings({ ...settings, maxIterations: parseInt(e.target.value) || 100 })}
|
||||
value={settings.trials}
|
||||
onChange={(e) => setSettings({ ...settings, trials: parseInt(e.target.value) || 300 })}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -438,55 +557,92 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, hori
|
||||
{/* Actions */}
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Start / Kill Button */}
|
||||
{/* Start / Pause / Resume / Kill Buttons */}
|
||||
{isRunning ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={actionInProgress !== null}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-600 hover:bg-red-500
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
text-white rounded-lg transition-colors font-medium"
|
||||
title="Force kill the optimization process and all child processes"
|
||||
>
|
||||
{actionInProgress === 'stop' ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<>
|
||||
{/* Pause/Resume Button */}
|
||||
{isPaused ? (
|
||||
<button
|
||||
onClick={handleResume}
|
||||
disabled={actionInProgress !== null}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-green-600 hover:bg-green-500
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
{actionInProgress === 'resume' ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-5 h-5" />
|
||||
)}
|
||||
Resume
|
||||
</button>
|
||||
) : (
|
||||
<Skull className="w-5 h-5" />
|
||||
<button
|
||||
onClick={handlePause}
|
||||
disabled={actionInProgress !== null}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-yellow-600 hover:bg-yellow-500
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
{actionInProgress === 'pause' ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Pause className="w-5 h-5" />
|
||||
)}
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
Kill Process
|
||||
</button>
|
||||
{/* Kill Button */}
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={actionInProgress !== null}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-600 hover:bg-red-500
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
text-white rounded-lg transition-colors font-medium"
|
||||
title="Force kill the optimization process and all child processes"
|
||||
>
|
||||
{actionInProgress === 'stop' ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Skull className="w-5 h-5" />
|
||||
)}
|
||||
Kill Process
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={actionInProgress !== null}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-green-600 hover:bg-green-500
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
{actionInProgress === 'start' ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-5 h-5" />
|
||||
)}
|
||||
Start Optimization
|
||||
</button>
|
||||
)}
|
||||
<>
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={actionInProgress !== null}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-green-600 hover:bg-green-500
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
{actionInProgress === 'start' ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-5 h-5" />
|
||||
)}
|
||||
Start Optimization
|
||||
</button>
|
||||
|
||||
{/* Validate Button */}
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
disabled={actionInProgress !== null || isRunning}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-primary-600 hover:bg-primary-500
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
{actionInProgress === 'validate' ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
)}
|
||||
Validate Top {validateTopN}
|
||||
</button>
|
||||
{/* Validate Button */}
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
disabled={actionInProgress !== null}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-primary-600 hover:bg-primary-500
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
{actionInProgress === 'validate' ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
)}
|
||||
Validate Top {validateTopN}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Validation Settings */}
|
||||
@@ -504,21 +660,30 @@ export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange, hori
|
||||
</div>
|
||||
|
||||
{/* Optuna Dashboard Button */}
|
||||
<button
|
||||
onClick={handleLaunchOptuna}
|
||||
disabled={actionInProgress !== null}
|
||||
className="w-full mt-4 flex items-center justify-center gap-2 px-4 py-2
|
||||
bg-dark-700 hover:bg-dark-600 border border-dark-600
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
text-dark-300 hover:text-white rounded-lg transition-colors text-sm"
|
||||
>
|
||||
{actionInProgress === 'optuna' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={handleLaunchOptuna}
|
||||
disabled={actionInProgress !== null || optunaAvailable === false}
|
||||
className={`w-full flex items-center justify-center gap-2 px-4 py-2
|
||||
bg-dark-700 hover:bg-dark-600 border border-dark-600
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
text-dark-300 hover:text-white rounded-lg transition-colors text-sm
|
||||
${optunaAvailable === false ? 'cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{actionInProgress === 'optuna' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
)}
|
||||
Launch Optuna Dashboard
|
||||
</button>
|
||||
{optunaAvailable === false && optunaInstallHint && (
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-dark-500">
|
||||
<Info className="w-3 h-3" />
|
||||
Install with: {optunaInstallHint}
|
||||
</div>
|
||||
)}
|
||||
Launch Optuna Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle, PauseCircle, StopCircle, PlayCircle, AlertCircle } from 'lucide-react';
|
||||
|
||||
export type OptimizationStatus = 'running' | 'paused' | 'stopped' | 'completed' | 'error' | 'not_started';
|
||||
|
||||
Reference in New Issue
Block a user