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:
Antoine
2025-12-04 15:02:13 -05:00
parent 8cbdbcad78
commit 9eed4d81eb
23 changed files with 5060 additions and 339 deletions

View File

@@ -0,0 +1,450 @@
import React, { useState, useRef, useEffect } from 'react';
import {
Send,
Bot,
User,
Sparkles,
Loader2,
X,
Maximize2,
Minimize2,
AlertCircle,
Wrench,
ChevronDown,
ChevronUp,
Trash2
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useStudy } from '../context/StudyContext';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
toolCalls?: Array<{
tool: string;
input: Record<string, any>;
result_preview: string;
}>;
}
interface ClaudeChatProps {
isExpanded?: boolean;
onToggleExpand?: () => void;
onClose?: () => void;
}
export const ClaudeChat: React.FC<ClaudeChatProps> = ({
isExpanded = false,
onToggleExpand,
onClose
}) => {
const { selectedStudy } = useStudy();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiAvailable, setApiAvailable] = useState<boolean | null>(null);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Check API status on mount
useEffect(() => {
checkApiStatus();
}, []);
// Load suggestions when study changes
useEffect(() => {
loadSuggestions();
}, [selectedStudy]);
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const checkApiStatus = async () => {
try {
const response = await fetch('/api/claude/status');
const data = await response.json();
setApiAvailable(data.available);
if (!data.available) {
setError(data.message);
}
} catch (err) {
setApiAvailable(false);
setError('Could not connect to Claude API');
}
};
const loadSuggestions = async () => {
try {
const url = selectedStudy
? `/api/claude/suggestions?study_id=${selectedStudy.id}`
: '/api/claude/suggestions';
const response = await fetch(url);
const data = await response.json();
setSuggestions(data.suggestions || []);
} catch (err) {
setSuggestions([]);
}
};
const sendMessage = async (messageText?: string) => {
const text = messageText || input.trim();
if (!text || isLoading) return;
setError(null);
const userMessage: Message = {
id: `user-${Date.now()}`,
role: 'user',
content: text,
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
const response = await fetch('/api/claude/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: text,
study_id: selectedStudy?.id,
conversation_history: messages.map(m => ({
role: m.role,
content: m.content
}))
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to get response');
}
const data = await response.json();
const assistantMessage: Message = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: data.response,
timestamp: new Date(),
toolCalls: data.tool_calls
};
setMessages(prev => [...prev, assistantMessage]);
} catch (err: any) {
setError(err.message || 'Failed to send message');
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
};
const clearConversation = () => {
setMessages([]);
setError(null);
};
const toggleToolExpand = (toolId: string) => {
setExpandedTools(prev => {
const newSet = new Set(prev);
if (newSet.has(toolId)) {
newSet.delete(toolId);
} else {
newSet.add(toolId);
}
return newSet;
});
};
// Render tool call indicator
const renderToolCalls = (toolCalls: Message['toolCalls'], messageId: string) => {
if (!toolCalls || toolCalls.length === 0) return null;
return (
<div className="mt-2 space-y-1">
{toolCalls.map((tool, index) => {
const toolId = `${messageId}-tool-${index}`;
const isExpanded = expandedTools.has(toolId);
return (
<div
key={index}
className="bg-dark-700/50 rounded-lg border border-dark-600 overflow-hidden"
>
<button
onClick={() => toggleToolExpand(toolId)}
className="w-full px-3 py-2 flex items-center justify-between text-xs hover:bg-dark-600/50 transition-colors"
>
<div className="flex items-center gap-2">
<Wrench className="w-3 h-3 text-primary-400" />
<span className="text-dark-300">Used tool: </span>
<span className="text-primary-400 font-mono">{tool.tool}</span>
</div>
{isExpanded ? (
<ChevronUp className="w-3 h-3 text-dark-400" />
) : (
<ChevronDown className="w-3 h-3 text-dark-400" />
)}
</button>
{isExpanded && (
<div className="px-3 py-2 border-t border-dark-600 text-xs">
<div className="text-dark-400 mb-1">Input:</div>
<pre className="text-dark-300 bg-dark-800 p-2 rounded overflow-x-auto">
{JSON.stringify(tool.input, null, 2)}
</pre>
<div className="text-dark-400 mt-2 mb-1">Result preview:</div>
<pre className="text-dark-300 bg-dark-800 p-2 rounded overflow-x-auto whitespace-pre-wrap">
{tool.result_preview}
</pre>
</div>
)}
</div>
);
})}
</div>
);
};
return (
<div className={`flex flex-col bg-dark-800 rounded-xl border border-dark-600 overflow-hidden ${
isExpanded ? 'fixed inset-4 z-50' : 'h-full'
}`}>
{/* Header */}
<div className="px-4 py-3 border-b border-dark-600 flex items-center justify-between bg-dark-800">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center">
<Bot className="w-4 h-4 text-white" />
</div>
<div>
<span className="font-medium text-white">Claude Code</span>
{selectedStudy && (
<span className="ml-2 text-xs bg-dark-700 px-2 py-0.5 rounded text-dark-300">
{selectedStudy.id}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
{messages.length > 0 && (
<button
onClick={clearConversation}
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
title="Clear conversation"
>
<Trash2 className="w-4 h-4" />
</button>
)}
{onToggleExpand && (
<button
onClick={onToggleExpand}
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
>
{isExpanded ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
{onClose && (
<button
onClick={onClose}
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* API Status Warning */}
{apiAvailable === false && (
<div className="px-4 py-3 bg-yellow-900/20 border-b border-yellow-800/30 flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-yellow-500" />
<span className="text-yellow-400 text-sm">
{error || 'Claude API not available'}
</span>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 ? (
<div className="text-center py-8">
<Sparkles className="w-12 h-12 mx-auto mb-4 text-primary-400 opacity-50" />
<p className="text-dark-300 mb-2">Ask me anything about your optimization</p>
<p className="text-dark-500 text-sm mb-6">
I can analyze results, explain concepts, and help you improve your designs.
</p>
{/* Suggestions */}
{suggestions.length > 0 && (
<div className="flex flex-wrap gap-2 justify-center max-w-md mx-auto">
{suggestions.slice(0, 6).map((suggestion, i) => (
<button
key={i}
onClick={() => sendMessage(suggestion)}
disabled={isLoading || apiAvailable === false}
className="px-3 py-1.5 bg-dark-700 hover:bg-dark-600 disabled:opacity-50
rounded-lg text-sm text-dark-300 hover:text-white transition-colors
border border-dark-600 hover:border-dark-500"
>
{suggestion}
</button>
))}
</div>
)}
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`flex gap-3 ${msg.role === 'user' ? 'justify-end' : ''}`}
>
{msg.role === 'assistant' && (
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4 text-white" />
</div>
)}
<div
className={`max-w-[85%] rounded-lg ${
msg.role === 'user'
? 'bg-primary-600 text-white px-4 py-2'
: 'bg-dark-700 text-dark-200 px-4 py-3'
}`}
>
{msg.role === 'assistant' ? (
<>
<div className="prose prose-sm prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Simplified markdown styling for chat
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
ul: ({ children }) => <ul className="list-disc list-inside mb-2">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside mb-2">{children}</ol>,
li: ({ children }) => <li className="mb-1">{children}</li>,
code: ({ inline, children }: any) =>
inline ? (
<code className="px-1 py-0.5 bg-dark-600 rounded text-primary-400 text-xs">
{children}
</code>
) : (
<pre className="p-2 bg-dark-800 rounded overflow-x-auto text-xs my-2">
<code>{children}</code>
</pre>
),
table: ({ children }) => (
<div className="overflow-x-auto my-2">
<table className="text-xs border-collapse">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border border-dark-600 px-2 py-1 bg-dark-800 text-left">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-dark-600 px-2 py-1">{children}</td>
),
strong: ({ children }) => <strong className="text-white">{children}</strong>,
h1: ({ children }) => <h1 className="text-lg font-bold text-white mt-4 mb-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-base font-semibold text-white mt-3 mb-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-semibold text-white mt-2 mb-1">{children}</h3>,
}}
>
{msg.content}
</ReactMarkdown>
</div>
{renderToolCalls(msg.toolCalls, msg.id)}
</>
) : (
<p>{msg.content}</p>
)}
</div>
{msg.role === 'user' && (
<div className="w-8 h-8 rounded-lg bg-dark-600 flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4 text-dark-300" />
</div>
)}
</div>
))
)}
{/* Loading indicator */}
{isLoading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
<Loader2 className="w-4 h-4 text-white animate-spin" />
</div>
<div className="bg-dark-700 rounded-lg px-4 py-3 text-dark-400">
<span className="inline-flex items-center gap-2">
<span>Thinking</span>
<span className="flex gap-1">
<span className="w-1.5 h-1.5 bg-dark-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1.5 h-1.5 bg-dark-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1.5 h-1.5 bg-dark-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</span>
</div>
</div>
)}
{/* Error message */}
{error && !isLoading && (
<div className="flex items-center gap-2 px-4 py-2 bg-red-900/20 border border-red-800/30 rounded-lg">
<AlertCircle className="w-4 h-4 text-red-400" />
<span className="text-red-400 text-sm">{error}</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 border-t border-dark-600 bg-dark-800">
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
placeholder={apiAvailable === false ? 'API not available...' : 'Ask about your optimization...'}
disabled={isLoading || apiAvailable === false}
className="flex-1 px-4 py-2.5 bg-dark-700 border border-dark-600 rounded-lg
text-white placeholder-dark-400 focus:outline-none focus:border-primary-500
disabled:opacity-50 disabled:cursor-not-allowed"
/>
<button
onClick={() => sendMessage()}
disabled={!input.trim() || isLoading || apiAvailable === false}
className="px-4 py-2.5 bg-primary-600 hover:bg-primary-500 disabled:opacity-50
disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
{isLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
<p className="text-xs text-dark-500 mt-2 text-center">
Claude can query your study data, analyze results, and help improve your optimization.
</p>
</div>
</div>
);
};
export default ClaudeChat;

View File

@@ -0,0 +1,336 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import 'xterm/css/xterm.css';
import {
Terminal as TerminalIcon,
Maximize2,
Minimize2,
X,
RefreshCw,
AlertCircle
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
interface ClaudeTerminalProps {
isExpanded?: boolean;
onToggleExpand?: () => void;
onClose?: () => void;
}
export const ClaudeTerminal: React.FC<ClaudeTerminalProps> = ({
isExpanded = false,
onToggleExpand,
onClose
}) => {
const { selectedStudy } = useStudy();
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [_error, setError] = useState<string | null>(null);
const [cliAvailable, setCliAvailable] = useState<boolean | null>(null);
// Check CLI availability
useEffect(() => {
fetch('/api/terminal/status')
.then(res => res.json())
.then(data => {
setCliAvailable(data.available);
if (!data.available) {
setError(data.message);
}
})
.catch(() => {
setCliAvailable(false);
setError('Could not check Claude Code CLI status');
});
}, []);
// Initialize terminal
useEffect(() => {
if (!terminalRef.current || xtermRef.current) return;
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: '"JetBrains Mono", "Fira Code", Consolas, monospace',
theme: {
background: '#0f172a',
foreground: '#e2e8f0',
cursor: '#60a5fa',
cursorAccent: '#0f172a',
selectionBackground: '#334155',
black: '#1e293b',
red: '#ef4444',
green: '#22c55e',
yellow: '#eab308',
blue: '#3b82f6',
magenta: '#a855f7',
cyan: '#06b6d4',
white: '#f1f5f9',
brightBlack: '#475569',
brightRed: '#f87171',
brightGreen: '#4ade80',
brightYellow: '#facc15',
brightBlue: '#60a5fa',
brightMagenta: '#c084fc',
brightCyan: '#22d3ee',
brightWhite: '#f8fafc',
},
allowProposedApi: true,
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
term.loadAddon(fitAddon);
term.loadAddon(webLinksAddon);
term.open(terminalRef.current);
// Initial fit
setTimeout(() => fitAddon.fit(), 0);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
// Welcome message
term.writeln('\x1b[1;36m╔══════════════════════════════════════════════════════════╗\x1b[0m');
term.writeln('\x1b[1;36m║\x1b[0m \x1b[1;37mClaude Code Terminal\x1b[0m \x1b[1;36m║\x1b[0m');
term.writeln('\x1b[1;36m║\x1b[0m \x1b[90mFull Claude Code experience in the Atomizer dashboard\x1b[0m \x1b[1;36m║\x1b[0m');
term.writeln('\x1b[1;36m╚══════════════════════════════════════════════════════════╝\x1b[0m');
term.writeln('');
if (cliAvailable === false) {
term.writeln('\x1b[1;31mError:\x1b[0m Claude Code CLI not found.');
term.writeln('Install with: \x1b[1;33mnpm install -g @anthropic-ai/claude-code\x1b[0m');
} else {
term.writeln('\x1b[90mClick "Connect" to start a Claude Code session.\x1b[0m');
term.writeln('\x1b[90mClaude will have access to CLAUDE.md and .claude/ skills.\x1b[0m');
}
term.writeln('');
return () => {
term.dispose();
xtermRef.current = null;
};
}, [cliAvailable]);
// Handle resize
useEffect(() => {
const handleResize = () => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
// Send resize to backend
if (wsRef.current?.readyState === WebSocket.OPEN && xtermRef.current) {
wsRef.current.send(JSON.stringify({
type: 'resize',
cols: xtermRef.current.cols,
rows: xtermRef.current.rows
}));
}
}
};
window.addEventListener('resize', handleResize);
// Also fit when expanded state changes
setTimeout(handleResize, 100);
return () => window.removeEventListener('resize', handleResize);
}, [isExpanded]);
// Connect to terminal WebSocket
const connect = useCallback(() => {
if (!xtermRef.current || wsRef.current?.readyState === WebSocket.OPEN) return;
setIsConnecting(true);
setError(null);
// Determine working directory - use study path if available
let workingDir = '';
if (selectedStudy?.id) {
// The study directory path
workingDir = `?working_dir=C:/Users/Antoine/Atomizer`;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/api/terminal/claude${workingDir}`);
ws.onopen = () => {
setIsConnected(true);
setIsConnecting(false);
xtermRef.current?.clear();
xtermRef.current?.writeln('\x1b[1;32mConnected to Claude Code\x1b[0m');
xtermRef.current?.writeln('');
// Send initial resize
if (xtermRef.current) {
ws.send(JSON.stringify({
type: 'resize',
cols: xtermRef.current.cols,
rows: xtermRef.current.rows
}));
}
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
switch (message.type) {
case 'output':
xtermRef.current?.write(message.data);
break;
case 'started':
xtermRef.current?.writeln(`\x1b[90m${message.message}\x1b[0m`);
break;
case 'exit':
xtermRef.current?.writeln('');
xtermRef.current?.writeln(`\x1b[33mClaude Code exited with code ${message.code}\x1b[0m`);
setIsConnected(false);
break;
case 'error':
xtermRef.current?.writeln(`\x1b[1;31mError: ${message.message}\x1b[0m`);
setError(message.message);
break;
}
} catch (e) {
// Raw output
xtermRef.current?.write(event.data);
}
};
ws.onerror = () => {
setError('WebSocket connection error');
setIsConnecting(false);
};
ws.onclose = () => {
setIsConnected(false);
setIsConnecting(false);
xtermRef.current?.writeln('');
xtermRef.current?.writeln('\x1b[90mDisconnected from Claude Code\x1b[0m');
};
// Handle terminal input
const disposable = xtermRef.current.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }));
}
});
wsRef.current = ws;
return () => {
disposable.dispose();
};
}, [selectedStudy]);
// Disconnect
const disconnect = useCallback(() => {
if (wsRef.current) {
wsRef.current.send(JSON.stringify({ type: 'stop' }));
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (wsRef.current) {
wsRef.current.close();
}
};
}, []);
return (
<div className={`flex flex-col bg-dark-800 rounded-xl border border-dark-600 overflow-hidden ${
isExpanded ? 'fixed inset-4 z-50' : 'h-full'
}`}>
{/* Header */}
<div className="px-4 py-3 border-b border-dark-600 flex items-center justify-between bg-dark-800">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center">
<TerminalIcon className="w-4 h-4 text-white" />
</div>
<div>
<span className="font-medium text-white">Claude Code</span>
{selectedStudy && (
<span className="ml-2 text-xs bg-dark-700 px-2 py-0.5 rounded text-dark-300">
{selectedStudy.id}
</span>
)}
</div>
{/* Connection status indicator */}
<div className={`w-2 h-2 rounded-full ml-2 ${
isConnected ? 'bg-green-500' : 'bg-dark-500'
}`} />
</div>
<div className="flex items-center gap-1">
{/* Connect/Disconnect button */}
<button
onClick={isConnected ? disconnect : connect}
disabled={isConnecting || cliAvailable === false}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-2 ${
isConnected
? 'bg-red-600/20 text-red-400 hover:bg-red-600/30'
: 'bg-green-600/20 text-green-400 hover:bg-green-600/30'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isConnecting ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : null}
{isConnected ? 'Disconnect' : 'Connect'}
</button>
{onToggleExpand && (
<button
onClick={onToggleExpand}
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
>
{isExpanded ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
{onClose && (
<button
onClick={onClose}
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* CLI not available warning */}
{cliAvailable === false && (
<div className="px-4 py-3 bg-yellow-900/20 border-b border-yellow-800/30 flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-yellow-500" />
<span className="text-yellow-400 text-sm">
Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code
</span>
</div>
)}
{/* Terminal */}
<div className="flex-1 p-2 bg-[#0f172a]">
<div ref={terminalRef} className="h-full" />
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-dark-600 bg-dark-800">
<p className="text-xs text-dark-500 text-center">
Claude Code has access to CLAUDE.md instructions and .claude/ skills for Atomizer optimization
</p>
</div>
</div>
);
};
export default ClaudeTerminal;

View File

@@ -0,0 +1,355 @@
import React, { useState, useEffect } from 'react';
import {
Play,
CheckCircle,
Settings,
AlertTriangle,
Loader2,
ExternalLink,
Sliders,
Skull
} from 'lucide-react';
import { apiClient, ProcessStatus } from '../../api/client';
import { useStudy } from '../../context/StudyContext';
interface ControlPanelProps {
onStatusChange?: () => void;
}
export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange }) => {
const { selectedStudy, refreshStudies } = useStudy();
const [processStatus, setProcessStatus] = useState<ProcessStatus | null>(null);
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
// Settings for starting optimization
const [settings, setSettings] = useState({
freshStart: false,
maxIterations: 100,
feaBatchSize: 5,
tuneTrials: 30,
ensembleSize: 3,
patience: 5,
});
// Validate top N
const [validateTopN, setValidateTopN] = useState(5);
useEffect(() => {
if (selectedStudy) {
fetchProcessStatus();
const interval = setInterval(fetchProcessStatus, 5000);
return () => clearInterval(interval);
}
}, [selectedStudy]);
const fetchProcessStatus = async () => {
if (!selectedStudy) return;
try {
const status = await apiClient.getProcessStatus(selectedStudy.id);
setProcessStatus(status);
} catch (err) {
// Process status endpoint might not exist yet
setProcessStatus(null);
}
};
const handleStart = async () => {
if (!selectedStudy) return;
setActionInProgress('start');
setError(null);
try {
await apiClient.startOptimization(selectedStudy.id, {
freshStart: settings.freshStart,
maxIterations: settings.maxIterations,
feaBatchSize: settings.feaBatchSize,
tuneTrials: settings.tuneTrials,
ensembleSize: settings.ensembleSize,
patience: settings.patience,
});
await fetchProcessStatus();
await refreshStudies();
onStatusChange?.();
} catch (err: any) {
setError(err.message || 'Failed to start optimization');
} finally {
setActionInProgress(null);
}
};
const handleStop = async () => {
if (!selectedStudy) return;
setActionInProgress('stop');
setError(null);
try {
await apiClient.stopOptimization(selectedStudy.id);
await fetchProcessStatus();
await refreshStudies();
onStatusChange?.();
} catch (err: any) {
setError(err.message || 'Failed to stop optimization');
} finally {
setActionInProgress(null);
}
};
const handleValidate = async () => {
if (!selectedStudy) return;
setActionInProgress('validate');
setError(null);
try {
await apiClient.validateOptimization(selectedStudy.id, { topN: validateTopN });
await fetchProcessStatus();
await refreshStudies();
onStatusChange?.();
} catch (err: any) {
setError(err.message || 'Failed to start validation');
} finally {
setActionInProgress(null);
}
};
const handleLaunchOptuna = async () => {
if (!selectedStudy) return;
setActionInProgress('optuna');
setError(null);
try {
const result = await apiClient.launchOptunaDashboard(selectedStudy.id);
window.open(result.url, '_blank');
} catch (err: any) {
setError(err.message || 'Failed to launch Optuna dashboard');
} finally {
setActionInProgress(null);
}
};
const isRunning = processStatus?.is_running || selectedStudy?.status === 'running';
return (
<div className="bg-dark-800 rounded-xl border border-dark-600 overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-dark-600 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Sliders className="w-5 h-5 text-primary-400" />
Optimization Control
</h2>
<button
onClick={() => setShowSettings(!showSettings)}
className={`p-2 rounded-lg transition-colors ${
showSettings ? 'bg-primary-600 text-white' : 'bg-dark-700 text-dark-300 hover:text-white'
}`}
>
<Settings className="w-4 h-4" />
</button>
</div>
{/* Status */}
<div className="px-6 py-4 border-b border-dark-700">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-dark-400 mb-1">Status</div>
<div className="flex items-center gap-2">
{isRunning ? (
<>
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse" />
<span className="text-green-400 font-medium">Running</span>
</>
) : (
<>
<div className="w-3 h-3 bg-dark-500 rounded-full" />
<span className="text-dark-400">Stopped</span>
</>
)}
</div>
</div>
{processStatus && (
<div className="text-right">
{processStatus.iteration && (
<div className="text-sm text-dark-400">
Iteration: <span className="text-white">{processStatus.iteration}</span>
</div>
)}
{processStatus.fea_count && (
<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></>
)}
</div>
)}
</div>
)}
</div>
</div>
{/* Settings Panel */}
{showSettings && (
<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>
<input
type="number"
value={settings.maxIterations}
onChange={(e) => setSettings({ ...settings, maxIterations: parseInt(e.target.value) || 100 })}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white text-sm"
/>
</div>
<div>
<label className="block text-xs text-dark-400 mb-1">FEA Batch Size</label>
<input
type="number"
value={settings.feaBatchSize}
onChange={(e) => setSettings({ ...settings, feaBatchSize: parseInt(e.target.value) || 5 })}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white text-sm"
/>
</div>
<div>
<label className="block text-xs text-dark-400 mb-1">Patience</label>
<input
type="number"
value={settings.patience}
onChange={(e) => setSettings({ ...settings, patience: parseInt(e.target.value) || 5 })}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white text-sm"
/>
</div>
<div>
<label className="block text-xs text-dark-400 mb-1">Tuning Trials</label>
<input
type="number"
value={settings.tuneTrials}
onChange={(e) => setSettings({ ...settings, tuneTrials: parseInt(e.target.value) || 30 })}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white text-sm"
/>
</div>
<div>
<label className="block text-xs text-dark-400 mb-1">Ensemble Size</label>
<input
type="number"
value={settings.ensembleSize}
onChange={(e) => setSettings({ ...settings, ensembleSize: parseInt(e.target.value) || 3 })}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white text-sm"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={settings.freshStart}
onChange={(e) => setSettings({ ...settings, freshStart: e.target.checked })}
className="w-4 h-4 rounded border-dark-600 bg-dark-700 text-primary-600"
/>
<span className="text-sm text-dark-300">Fresh Start</span>
</label>
</div>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="px-6 py-3 bg-red-900/20 border-b border-red-800/30">
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertTriangle className="w-4 h-4" />
{error}
</div>
</div>
)}
{/* Actions */}
<div className="p-6">
<div className="grid grid-cols-2 gap-3">
{/* Start / Kill Button */}
{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" />
) : (
<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>
)}
{/* 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>
</div>
{/* Validation Settings */}
<div className="mt-3 flex items-center gap-2">
<span className="text-sm text-dark-400">Validate top</span>
<input
type="number"
min={1}
max={20}
value={validateTopN}
onChange={(e) => setValidateTopN(parseInt(e.target.value) || 5)}
className="w-16 px-2 py-1 bg-dark-700 border border-dark-600 rounded text-white text-sm text-center"
/>
<span className="text-sm text-dark-400">NN predictions with FEA</span>
</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" />
)}
Launch Optuna Dashboard
</button>
</div>
</div>
);
};
export default ControlPanel;

View File

@@ -1,31 +1,109 @@
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, Settings, FileText, Activity } from 'lucide-react';
import { NavLink, useNavigate } from 'react-router-dom';
import {
Home,
Activity,
FileText,
BarChart3,
ChevronLeft,
Play,
CheckCircle,
Clock,
Zap
} from 'lucide-react';
import clsx from 'clsx';
import { useStudy } from '../../context/StudyContext';
export const Sidebar = () => {
const navItems = [
{ to: '/dashboard', icon: Activity, label: 'Live Dashboard' },
{ to: '/configurator', icon: Settings, label: 'Configurator' },
{ to: '/results', icon: FileText, label: 'Results Viewer' },
];
const { selectedStudy, clearStudy } = useStudy();
const navigate = useNavigate();
const handleBackToHome = () => {
clearStudy();
navigate('/');
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'running':
return <Play className="w-3 h-3 text-green-400" />;
case 'completed':
return <CheckCircle className="w-3 h-3 text-blue-400" />;
default:
return <Clock className="w-3 h-3 text-dark-400" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'running':
return 'text-green-400';
case 'completed':
return 'text-blue-400';
default:
return 'text-dark-400';
}
};
// Navigation items depend on whether a study is selected
const navItems = selectedStudy
? [
{ to: '/dashboard', icon: Activity, label: 'Live Tracker' },
{ to: '/results', icon: FileText, label: 'Reports' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
]
: [
{ to: '/', icon: Home, label: 'Select Study' },
];
return (
<aside className="w-64 bg-dark-800 border-r border-dark-600 flex flex-col h-screen fixed left-0 top-0">
{/* Header */}
<div className="p-6 border-b border-dark-600">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<LayoutDashboard className="w-5 h-5 text-white" />
<Zap className="w-5 h-5 text-white" />
</div>
<h1 className="text-xl font-bold text-white tracking-tight">Atomizer</h1>
</div>
<p className="text-xs text-dark-300 mt-1 ml-11">Optimization Platform</p>
</div>
{/* Selected Study Info */}
{selectedStudy && (
<div className="p-4 border-b border-dark-600">
<button
onClick={handleBackToHome}
className="flex items-center gap-2 text-sm text-dark-400 hover:text-white
transition-colors mb-3 group"
>
<ChevronLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
Change Study
</button>
<div className="bg-dark-700 rounded-lg p-3">
<div className="text-xs font-medium text-dark-400 uppercase mb-1">Active Study</div>
<div className="text-white font-medium truncate">{selectedStudy.name}</div>
<div className="flex items-center gap-2 mt-2">
<span className={`flex items-center gap-1 text-xs ${getStatusColor(selectedStudy.status)}`}>
{getStatusIcon(selectedStudy.status)}
{selectedStudy.status}
</span>
<span className="text-dark-500">|</span>
<span className="text-xs text-dark-400">
{selectedStudy.progress.current}/{selectedStudy.progress.total}
</span>
</div>
</div>
</div>
)}
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-colors duration-200',
@@ -41,6 +119,7 @@ export const Sidebar = () => {
))}
</nav>
{/* Footer Status */}
<div className="p-4 border-t border-dark-600">
<div className="bg-dark-700 rounded-lg p-4">
<div className="text-xs font-medium text-dark-400 uppercase mb-2">System Status</div>
@@ -48,8 +127,14 @@ export const Sidebar = () => {
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Backend Online
</div>
{selectedStudy && selectedStudy.status === 'running' && (
<div className="flex items-center gap-2 text-sm text-green-400 mt-1">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Optimization Running
</div>
)}
</div>
</div>
</aside>
);
};
};