feat: Add Claude Code terminal integration to dashboard
- Add embedded Claude Code terminal with xterm.js for full CLI experience - Create WebSocket PTY backend for real-time terminal communication - Add terminal status endpoint to check CLI availability - Update dashboard to use Claude Code terminal instead of API chat - Add optimization control panel with start/stop/validate actions - Add study context provider for global state management - Update frontend with new dependencies (xterm.js addons) - Comprehensive README documentation for all new features 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,12 +5,16 @@ Handles study status, history retrieval, and control operations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from pydantic import BaseModel
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
import json
|
||||
import sys
|
||||
import sqlite3
|
||||
import shutil
|
||||
import subprocess
|
||||
import psutil
|
||||
import signal
|
||||
from datetime import datetime
|
||||
|
||||
# Add project root to path
|
||||
@@ -1024,3 +1028,620 @@ async def get_study_report(study_id: str):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to read study report: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Study README and Config Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/studies/{study_id}/readme")
|
||||
async def get_study_readme(study_id: str):
|
||||
"""
|
||||
Get the README.md file content for a study (from 1_setup folder)
|
||||
|
||||
Args:
|
||||
study_id: Study identifier
|
||||
|
||||
Returns:
|
||||
JSON with the markdown content
|
||||
"""
|
||||
try:
|
||||
study_dir = STUDIES_DIR / study_id
|
||||
|
||||
if not study_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||
|
||||
# Look for README.md in various locations
|
||||
readme_paths = [
|
||||
study_dir / "README.md",
|
||||
study_dir / "1_setup" / "README.md",
|
||||
study_dir / "readme.md",
|
||||
]
|
||||
|
||||
readme_content = None
|
||||
readme_path = None
|
||||
|
||||
for path in readme_paths:
|
||||
if path.exists():
|
||||
readme_path = path
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
readme_content = f.read()
|
||||
break
|
||||
|
||||
if readme_content is None:
|
||||
# Generate a basic README from config if none exists
|
||||
config_file = study_dir / "1_setup" / "optimization_config.json"
|
||||
if not config_file.exists():
|
||||
config_file = study_dir / "optimization_config.json"
|
||||
|
||||
if config_file.exists():
|
||||
with open(config_file) as f:
|
||||
config = json.load(f)
|
||||
|
||||
readme_content = f"""# {config.get('study_name', study_id)}
|
||||
|
||||
{config.get('description', 'No description available.')}
|
||||
|
||||
## Design Variables
|
||||
{chr(10).join([f"- **{dv['name']}**: {dv.get('min', '?')} - {dv.get('max', '?')} {dv.get('units', '')}" for dv in config.get('design_variables', [])])}
|
||||
|
||||
## Objectives
|
||||
{chr(10).join([f"- **{obj['name']}**: {obj.get('description', '')} ({obj.get('direction', 'minimize')})" for obj in config.get('objectives', [])])}
|
||||
"""
|
||||
else:
|
||||
readme_content = f"# {study_id}\n\nNo README or configuration found for this study."
|
||||
|
||||
return {
|
||||
"content": readme_content,
|
||||
"path": str(readme_path) if readme_path else None,
|
||||
"study_id": study_id
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to read README: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/studies/{study_id}/config")
|
||||
async def get_study_config(study_id: str):
|
||||
"""
|
||||
Get the full optimization_config.json for a study
|
||||
|
||||
Args:
|
||||
study_id: Study identifier
|
||||
|
||||
Returns:
|
||||
JSON with the complete configuration
|
||||
"""
|
||||
try:
|
||||
study_dir = STUDIES_DIR / study_id
|
||||
|
||||
if not study_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||
|
||||
# Look for config in various locations
|
||||
config_file = study_dir / "1_setup" / "optimization_config.json"
|
||||
if not config_file.exists():
|
||||
config_file = study_dir / "optimization_config.json"
|
||||
|
||||
if not config_file.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Config file not found for study {study_id}")
|
||||
|
||||
with open(config_file) as f:
|
||||
config = json.load(f)
|
||||
|
||||
return {
|
||||
"config": config,
|
||||
"path": str(config_file),
|
||||
"study_id": study_id
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to read config: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Process Control Endpoints
|
||||
# ============================================================================
|
||||
|
||||
# Track running processes by study_id
|
||||
_running_processes: Dict[str, int] = {}
|
||||
|
||||
def _find_optimization_process(study_id: str) -> Optional[psutil.Process]:
|
||||
"""Find a running optimization process for a given study"""
|
||||
study_dir = STUDIES_DIR / study_id
|
||||
|
||||
for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'cwd']):
|
||||
try:
|
||||
cmdline = proc.info.get('cmdline') or []
|
||||
cmdline_str = ' '.join(cmdline) if cmdline else ''
|
||||
|
||||
# Check if this is a Python process running run_optimization.py for this study
|
||||
if 'python' in cmdline_str.lower() and 'run_optimization' in cmdline_str:
|
||||
if study_id in cmdline_str or str(study_dir) in cmdline_str:
|
||||
return proc
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/studies/{study_id}/process")
|
||||
async def get_process_status(study_id: str):
|
||||
"""
|
||||
Get the process status for a study's optimization run
|
||||
|
||||
Args:
|
||||
study_id: Study identifier
|
||||
|
||||
Returns:
|
||||
JSON with process status (is_running, pid, iteration counts)
|
||||
"""
|
||||
try:
|
||||
study_dir = STUDIES_DIR / study_id
|
||||
|
||||
if not study_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||
|
||||
# Check if process is running
|
||||
proc = _find_optimization_process(study_id)
|
||||
is_running = proc is not None
|
||||
pid = proc.pid if proc else None
|
||||
|
||||
# Get iteration counts from database
|
||||
results_dir = get_results_dir(study_dir)
|
||||
study_db = results_dir / "study.db"
|
||||
|
||||
fea_count = 0
|
||||
nn_count = 0
|
||||
iteration = None
|
||||
|
||||
if study_db.exists():
|
||||
try:
|
||||
conn = sqlite3.connect(str(study_db))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Count FEA trials (from main study or studies with "_fea" suffix)
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM trials t
|
||||
JOIN studies s ON t.study_id = s.study_id
|
||||
WHERE t.state = 'COMPLETE'
|
||||
AND (s.study_name LIKE '%_fea' OR s.study_name NOT LIKE '%_nn%')
|
||||
""")
|
||||
fea_count = cursor.fetchone()[0]
|
||||
|
||||
# Count NN trials
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM trials t
|
||||
JOIN studies s ON t.study_id = s.study_id
|
||||
WHERE t.state = 'COMPLETE'
|
||||
AND s.study_name LIKE '%_nn%'
|
||||
""")
|
||||
nn_count = cursor.fetchone()[0]
|
||||
|
||||
# Try to get current iteration from study names
|
||||
cursor.execute("""
|
||||
SELECT study_name FROM studies
|
||||
WHERE study_name LIKE '%_iter%'
|
||||
ORDER BY study_name DESC LIMIT 1
|
||||
""")
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
import re
|
||||
match = re.search(r'iter(\d+)', result[0])
|
||||
if match:
|
||||
iteration = int(match.group(1))
|
||||
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to read database for process status: {e}")
|
||||
|
||||
return {
|
||||
"is_running": is_running,
|
||||
"pid": pid,
|
||||
"iteration": iteration,
|
||||
"fea_count": fea_count,
|
||||
"nn_count": nn_count,
|
||||
"study_id": study_id
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get process status: {str(e)}")
|
||||
|
||||
|
||||
class StartOptimizationRequest(BaseModel):
|
||||
freshStart: bool = False
|
||||
maxIterations: int = 100
|
||||
feaBatchSize: int = 5
|
||||
tuneTrials: int = 30
|
||||
ensembleSize: int = 3
|
||||
patience: int = 5
|
||||
|
||||
|
||||
@router.post("/studies/{study_id}/start")
|
||||
async def start_optimization(study_id: str, request: StartOptimizationRequest = None):
|
||||
"""
|
||||
Start the optimization process for a study
|
||||
|
||||
Args:
|
||||
study_id: Study identifier
|
||||
request: Optional start options
|
||||
|
||||
Returns:
|
||||
JSON with process info
|
||||
"""
|
||||
try:
|
||||
study_dir = STUDIES_DIR / study_id
|
||||
|
||||
if not study_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||
|
||||
# Check if already running
|
||||
existing_proc = _find_optimization_process(study_id)
|
||||
if existing_proc:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Optimization already running (PID: {existing_proc.pid})",
|
||||
"pid": existing_proc.pid
|
||||
}
|
||||
|
||||
# Find run_optimization.py
|
||||
run_script = study_dir / "run_optimization.py"
|
||||
if not run_script.exists():
|
||||
raise HTTPException(status_code=404, detail=f"run_optimization.py not found for study {study_id}")
|
||||
|
||||
# Build command with arguments
|
||||
python_exe = sys.executable
|
||||
cmd = [python_exe, str(run_script), "--start"]
|
||||
|
||||
if request:
|
||||
if request.freshStart:
|
||||
cmd.append("--fresh")
|
||||
cmd.extend(["--fea-batch", str(request.feaBatchSize)])
|
||||
cmd.extend(["--tune-trials", str(request.tuneTrials)])
|
||||
cmd.extend(["--ensemble-size", str(request.ensembleSize)])
|
||||
cmd.extend(["--patience", str(request.patience)])
|
||||
|
||||
# Start process in background
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(study_dir),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
_running_processes[study_id] = proc.pid
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Optimization started successfully",
|
||||
"pid": proc.pid,
|
||||
"command": ' '.join(cmd)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to start optimization: {str(e)}")
|
||||
|
||||
|
||||
class StopRequest(BaseModel):
|
||||
force: bool = True # Default to force kill
|
||||
|
||||
|
||||
@router.post("/studies/{study_id}/stop")
|
||||
async def stop_optimization(study_id: str, request: StopRequest = None):
|
||||
"""
|
||||
Stop the optimization process for a study (hard kill by default)
|
||||
|
||||
Args:
|
||||
study_id: Study identifier
|
||||
request.force: If True (default), immediately kill. If False, try graceful first.
|
||||
|
||||
Returns:
|
||||
JSON with result
|
||||
"""
|
||||
if request is None:
|
||||
request = StopRequest()
|
||||
|
||||
try:
|
||||
study_dir = STUDIES_DIR / study_id
|
||||
|
||||
if not study_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||
|
||||
# Find running process
|
||||
proc = _find_optimization_process(study_id)
|
||||
|
||||
if not proc:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No running optimization process found"
|
||||
}
|
||||
|
||||
pid = proc.pid
|
||||
killed_pids = []
|
||||
|
||||
try:
|
||||
# FIRST: Get all children BEFORE killing parent
|
||||
children = []
|
||||
try:
|
||||
children = proc.children(recursive=True)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
if request.force:
|
||||
# Hard kill: immediately kill parent and all children
|
||||
# Kill children first (bottom-up)
|
||||
for child in reversed(children):
|
||||
try:
|
||||
child.kill() # SIGKILL on Unix, TerminateProcess on Windows
|
||||
killed_pids.append(child.pid)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
# Then kill parent
|
||||
try:
|
||||
proc.kill()
|
||||
killed_pids.append(pid)
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
else:
|
||||
# Graceful: try SIGTERM first, then force
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
except psutil.TimeoutExpired:
|
||||
# Didn't stop gracefully, force kill
|
||||
for child in reversed(children):
|
||||
try:
|
||||
child.kill()
|
||||
killed_pids.append(child.pid)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
proc.kill()
|
||||
killed_pids.append(pid)
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
|
||||
# Clean up tracking
|
||||
if study_id in _running_processes:
|
||||
del _running_processes[study_id]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Optimization killed (PID: {pid}, +{len(children)} children)",
|
||||
"pid": pid,
|
||||
"killed_pids": killed_pids
|
||||
}
|
||||
|
||||
except psutil.NoSuchProcess:
|
||||
if study_id in _running_processes:
|
||||
del _running_processes[study_id]
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Process already terminated"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to stop optimization: {str(e)}")
|
||||
|
||||
|
||||
class ValidateRequest(BaseModel):
|
||||
topN: int = 5
|
||||
|
||||
|
||||
@router.post("/studies/{study_id}/validate")
|
||||
async def validate_optimization(study_id: str, request: ValidateRequest = None):
|
||||
"""
|
||||
Run final FEA validation on top NN predictions
|
||||
|
||||
Args:
|
||||
study_id: Study identifier
|
||||
request: Validation options (topN)
|
||||
|
||||
Returns:
|
||||
JSON with process info
|
||||
"""
|
||||
try:
|
||||
study_dir = STUDIES_DIR / study_id
|
||||
|
||||
if not study_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||
|
||||
# Check if optimization is still running
|
||||
existing_proc = _find_optimization_process(study_id)
|
||||
if existing_proc:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Cannot validate while optimization is running. Stop optimization first."
|
||||
}
|
||||
|
||||
# Look for final_validation.py script
|
||||
validation_script = study_dir / "final_validation.py"
|
||||
|
||||
if not validation_script.exists():
|
||||
# Fall back to run_optimization.py with --validate flag if script doesn't exist
|
||||
run_script = study_dir / "run_optimization.py"
|
||||
if not run_script.exists():
|
||||
raise HTTPException(status_code=404, detail="No validation script found")
|
||||
|
||||
python_exe = sys.executable
|
||||
top_n = request.topN if request else 5
|
||||
cmd = [python_exe, str(run_script), "--validate", "--top", str(top_n)]
|
||||
else:
|
||||
python_exe = sys.executable
|
||||
top_n = request.topN if request else 5
|
||||
cmd = [python_exe, str(validation_script), "--top", str(top_n)]
|
||||
|
||||
# Start validation process
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(study_dir),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Validation started for top {top_n} NN predictions",
|
||||
"pid": proc.pid,
|
||||
"command": ' '.join(cmd)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to start validation: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Optuna Dashboard Launch
|
||||
# ============================================================================
|
||||
|
||||
_optuna_processes: Dict[str, subprocess.Popen] = {}
|
||||
|
||||
@router.post("/studies/{study_id}/optuna-dashboard")
|
||||
async def launch_optuna_dashboard(study_id: str):
|
||||
"""
|
||||
Launch Optuna dashboard for a specific study
|
||||
|
||||
Args:
|
||||
study_id: Study identifier
|
||||
|
||||
Returns:
|
||||
JSON with dashboard URL and process info
|
||||
"""
|
||||
import time
|
||||
import socket
|
||||
|
||||
def is_port_in_use(port: int) -> bool:
|
||||
"""Check if a port is already in use"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
return s.connect_ex(('localhost', port)) == 0
|
||||
|
||||
try:
|
||||
study_dir = STUDIES_DIR / study_id
|
||||
|
||||
if not study_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||
|
||||
results_dir = get_results_dir(study_dir)
|
||||
study_db = results_dir / "study.db"
|
||||
|
||||
if not study_db.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No Optuna database found for study {study_id}")
|
||||
|
||||
port = 8081
|
||||
|
||||
# Check if dashboard is already running on this port
|
||||
if is_port_in_use(port):
|
||||
# Check if it's our process
|
||||
if study_id in _optuna_processes:
|
||||
proc = _optuna_processes[study_id]
|
||||
if proc.poll() is None: # Still running
|
||||
return {
|
||||
"success": True,
|
||||
"url": f"http://localhost:{port}",
|
||||
"pid": proc.pid,
|
||||
"message": "Optuna dashboard already running"
|
||||
}
|
||||
# Port in use but not by us - still return success since dashboard is available
|
||||
return {
|
||||
"success": True,
|
||||
"url": f"http://localhost:{port}",
|
||||
"pid": None,
|
||||
"message": "Optuna dashboard already running on port 8081"
|
||||
}
|
||||
|
||||
# Launch optuna-dashboard using Python script
|
||||
python_exe = sys.executable
|
||||
# Use absolute path with POSIX format for SQLite URL
|
||||
abs_db_path = study_db.absolute().as_posix()
|
||||
storage_url = f"sqlite:///{abs_db_path}"
|
||||
|
||||
# Create a small Python script to run optuna-dashboard
|
||||
launch_script = f'''
|
||||
from optuna_dashboard import run_server
|
||||
run_server("{storage_url}", host="0.0.0.0", port={port})
|
||||
'''
|
||||
cmd = [python_exe, "-c", launch_script]
|
||||
|
||||
# On Windows, use CREATE_NEW_PROCESS_GROUP and DETACHED_PROCESS flags
|
||||
import platform
|
||||
if platform.system() == 'Windows':
|
||||
# Windows-specific: create detached process
|
||||
DETACHED_PROCESS = 0x00000008
|
||||
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
||||
)
|
||||
else:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
_optuna_processes[study_id] = proc
|
||||
|
||||
# Wait for dashboard to start (check port repeatedly)
|
||||
max_wait = 5 # seconds
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < max_wait:
|
||||
if is_port_in_use(port):
|
||||
return {
|
||||
"success": True,
|
||||
"url": f"http://localhost:{port}",
|
||||
"pid": proc.pid,
|
||||
"message": "Optuna dashboard launched successfully"
|
||||
}
|
||||
# Check if process died
|
||||
if proc.poll() is not None:
|
||||
stderr = ""
|
||||
try:
|
||||
stderr = proc.stderr.read().decode() if proc.stderr else ""
|
||||
except:
|
||||
pass
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Failed to start Optuna dashboard: {stderr}"
|
||||
}
|
||||
time.sleep(0.5)
|
||||
|
||||
# Timeout - process might still be starting
|
||||
if proc.poll() is None:
|
||||
return {
|
||||
"success": True,
|
||||
"url": f"http://localhost:{port}",
|
||||
"pid": proc.pid,
|
||||
"message": "Optuna dashboard starting (may take a moment)"
|
||||
}
|
||||
else:
|
||||
stderr = ""
|
||||
try:
|
||||
stderr = proc.stderr.read().decode() if proc.stderr else ""
|
||||
except:
|
||||
pass
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Failed to start Optuna dashboard: {stderr}"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to launch Optuna dashboard: {str(e)}")
|
||||
|
||||
Reference in New Issue
Block a user