feat: add token-based auth UI in dashboard
This commit is contained in:
parent
9e07fed45c
commit
188dafdc7a
1 changed files with 383 additions and 354 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import './App.css';
|
||||
|
||||
const App = () => {
|
||||
|
|
@ -6,12 +6,10 @@ const App = () => {
|
|||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
prompt: '',
|
||||
schedule_type: 'manual',
|
||||
schedule_value: '',
|
||||
enabled: true
|
||||
name: '', description: '', prompt: '', schedule_type: 'manual',
|
||||
schedule_value: '', enabled: true,
|
||||
agent_model: '', agent_tools: 'Bash,Read,Write,Edit',
|
||||
agent_system_prompt: '', agent_permission_mode: 'auto', agent_timeout: 300
|
||||
});
|
||||
const [systemInfo, setSystemInfo] = useState(null);
|
||||
const [usageStats, setUsageStats] = useState(null);
|
||||
|
|
@ -20,403 +18,296 @@ const App = () => {
|
|||
const [showAddMcp, setShowAddMcp] = useState(false);
|
||||
const [mcpForm, setMcpForm] = useState({ name: '', server_type: 'sse', url: '', command: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeView, setActiveView] = useState('tasks');
|
||||
const [activeView, setActiveView] = useState('chat'); // 'chat' | 'tasks' | 'dashboard'
|
||||
|
||||
// Auth token state
|
||||
// Auth
|
||||
const [authToken, setAuthToken] = useState('');
|
||||
const [tokenType, setTokenType] = useState('oauth_token');
|
||||
const [tokenSubmitting, setTokenSubmitting] = useState(false);
|
||||
|
||||
// Chat
|
||||
const [chatMessages, setChatMessages] = useState([]);
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
const [chatSending, setChatSending] = useState(false);
|
||||
const [chatSessionId, setChatSessionId] = useState(() => crypto.randomUUID ? crypto.randomUUID() : Date.now().toString());
|
||||
const [chatSessions, setChatSessions] = useState([]);
|
||||
const [chatModel, setChatModel] = useState('');
|
||||
const chatEndRef = useRef(null);
|
||||
const wsRef = useRef(null);
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
fetchSystemInfo();
|
||||
fetchUsageStats();
|
||||
fetchAuthStatus();
|
||||
fetchMcpServers();
|
||||
fetchTasks(); fetchSystemInfo(); fetchUsageStats(); fetchAuthStatus(); fetchMcpServers();
|
||||
const interval = setInterval(() => {
|
||||
fetchTasks();
|
||||
fetchSystemInfo();
|
||||
fetchUsageStats();
|
||||
fetchAuthStatus();
|
||||
fetchMcpServers();
|
||||
fetchTasks(); fetchSystemInfo(); fetchAuthStatus();
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tasks');
|
||||
const data = await response.json();
|
||||
setTasks(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tasks:', error);
|
||||
useEffect(() => {
|
||||
if (chatEndRef.current) chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [chatMessages]);
|
||||
|
||||
// WebSocket for chat
|
||||
const connectWs = useCallback(() => {
|
||||
if (wsRef.current && wsRef.current.readyState <= 1) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${proto}//${window.location.host}/api/chat/ws/${chatSessionId}`);
|
||||
ws.onopen = () => setWsConnected(true);
|
||||
ws.onclose = () => setWsConnected(false);
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'chunk') {
|
||||
setChatMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.role === 'assistant' && last.streaming) {
|
||||
return [...prev.slice(0, -1), { ...last, content: last.content + data.content }];
|
||||
}
|
||||
return [...prev, { role: 'assistant', content: data.content, streaming: true }];
|
||||
});
|
||||
} else if (data.type === 'done') {
|
||||
setChatMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.role === 'assistant' && last.streaming) {
|
||||
return [...prev.slice(0, -1), { ...last, streaming: false }];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
setChatSending(false);
|
||||
} else if (data.type === 'error') {
|
||||
setChatMessages(prev => [...prev, { role: 'error', content: data.content }]);
|
||||
setChatSending(false);
|
||||
} else if (data.type === 'status' && data.content === 'thinking') {
|
||||
setChatMessages(prev => [...prev, { role: 'assistant', content: '', streaming: true }]);
|
||||
}
|
||||
};
|
||||
wsRef.current = ws;
|
||||
}, [chatSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView === 'chat') connectWs();
|
||||
return () => {
|
||||
if (wsRef.current) { wsRef.current.close(); wsRef.current = null; }
|
||||
};
|
||||
}, [activeView, chatSessionId, connectWs]);
|
||||
|
||||
const sendChatMessage = async () => {
|
||||
if (!chatInput.trim() || chatSending) return;
|
||||
const msg = chatInput.trim();
|
||||
setChatInput('');
|
||||
setChatMessages(prev => [...prev, { role: 'user', content: msg }]);
|
||||
setChatSending(true);
|
||||
|
||||
if (wsRef.current && wsRef.current.readyState === 1) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
message: msg, model: chatModel || undefined
|
||||
}));
|
||||
} else {
|
||||
// Fallback to HTTP
|
||||
try {
|
||||
const response = await fetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: msg, session_id: chatSessionId, model: chatModel || undefined
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
setChatMessages(prev => [...prev, {
|
||||
role: data.status === 'ok' ? 'assistant' : 'error',
|
||||
content: data.response
|
||||
}]);
|
||||
} catch (error) {
|
||||
setChatMessages(prev => [...prev, { role: 'error', content: 'Failed to send message' }]);
|
||||
}
|
||||
setChatSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSystemInfo = async () => {
|
||||
const startNewChat = () => {
|
||||
setChatMessages([]);
|
||||
const newId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString();
|
||||
setChatSessionId(newId);
|
||||
if (wsRef.current) { wsRef.current.close(); wsRef.current = null; }
|
||||
};
|
||||
|
||||
const loadChatSession = async (sid) => {
|
||||
setChatSessionId(sid);
|
||||
if (wsRef.current) { wsRef.current.close(); wsRef.current = null; }
|
||||
try {
|
||||
const response = await fetch('/api/system/info');
|
||||
const response = await fetch(`/api/chat/history/${sid}`);
|
||||
const data = await response.json();
|
||||
setSystemInfo(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching system info:', error);
|
||||
setChatMessages(data.map(m => ({ role: m.role, content: m.content })));
|
||||
} catch (e) {
|
||||
console.error('Error loading chat session:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsageStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/system/usage');
|
||||
const data = await response.json();
|
||||
setUsageStats(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching usage stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAuthStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/status');
|
||||
const data = await response.json();
|
||||
setAuthStatus(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching auth status:', error);
|
||||
}
|
||||
};
|
||||
// Fetchers
|
||||
const fetchTasks = async () => { try { const r = await fetch('/api/tasks'); setTasks(await r.json()); } catch(e) {} };
|
||||
const fetchSystemInfo = async () => { try { const r = await fetch('/api/system/info'); setSystemInfo(await r.json()); } catch(e) {} };
|
||||
const fetchUsageStats = async () => { try { const r = await fetch('/api/system/usage'); setUsageStats(await r.json()); } catch(e) {} };
|
||||
const fetchAuthStatus = async () => { try { const r = await fetch('/api/auth/status'); setAuthStatus(await r.json()); } catch(e) {} };
|
||||
const fetchMcpServers = async () => { try { const r = await fetch('/api/mcp/servers'); setMcpServers(await r.json()); } catch(e) {} };
|
||||
const fetchChatSessions = async () => { try { const r = await fetch('/api/chat/sessions'); setChatSessions(await r.json()); } catch(e) {} };
|
||||
|
||||
const handleSubmitToken = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!authToken.trim()) return;
|
||||
setTokenSubmitting(true);
|
||||
try {
|
||||
const response = await fetch('/api/auth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const r = await fetch('/api/auth/token', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: authToken.trim(), token_type: tokenType })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.status === 'logged_in') {
|
||||
setAuthToken('');
|
||||
fetchAuthStatus();
|
||||
} else if (data.status === 'token_saved') {
|
||||
setAuthToken('');
|
||||
fetchAuthStatus();
|
||||
alert(data.message);
|
||||
} else {
|
||||
alert(data.message || 'Token submission failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting token:', error);
|
||||
alert('Failed to submit token');
|
||||
}
|
||||
const data = await r.json();
|
||||
setAuthToken('');
|
||||
fetchAuthStatus();
|
||||
if (data.status !== 'logged_in') alert(data.message);
|
||||
} catch(e) { alert('Failed to submit token'); }
|
||||
setTokenSubmitting(false);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
fetchAuthStatus();
|
||||
} catch (error) {
|
||||
console.error('Error logging out:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMcpServers = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/mcp/servers');
|
||||
const data = await response.json();
|
||||
setMcpServers(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching MCP servers:', error);
|
||||
}
|
||||
};
|
||||
const handleLogout = async () => { await fetch('/api/auth/logout', { method: 'POST' }); fetchAuthStatus(); };
|
||||
|
||||
const handleAddMcp = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch('/api/mcp/servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mcpForm)
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
setShowAddMcp(false);
|
||||
setMcpForm({ name: '', server_type: 'sse', url: '', command: '' });
|
||||
fetchMcpServers();
|
||||
} else {
|
||||
alert(data.message || 'Failed to add MCP server');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding MCP server:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMcp = async (name) => {
|
||||
if (!confirm(`Remove MCP server "${name}"?`)) return;
|
||||
try {
|
||||
await fetch(`/api/mcp/servers/${name}`, { method: 'DELETE' });
|
||||
fetchMcpServers();
|
||||
} catch (error) {
|
||||
console.error('Error removing MCP server:', error);
|
||||
}
|
||||
const r = await fetch('/api/mcp/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mcpForm) });
|
||||
const data = await r.json();
|
||||
if (data.status === 'ok') { setShowAddMcp(false); setMcpForm({ name: '', server_type: 'sse', url: '', command: '' }); fetchMcpServers(); }
|
||||
else alert(data.message || 'Failed');
|
||||
};
|
||||
const handleRemoveMcp = async (name) => { if (!confirm(`Remove "${name}"?`)) return; await fetch(`/api/mcp/servers/${name}`, { method: 'DELETE' }); fetchMcpServers(); };
|
||||
|
||||
const handleCreateTask = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
e.preventDefault(); setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
if (response.ok) {
|
||||
await fetchTasks();
|
||||
setShowForm(false);
|
||||
setFormData({ name: '', description: '', prompt: '', schedule_type: 'manual', schedule_value: '', enabled: true });
|
||||
const r = await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData) });
|
||||
if (r.ok) {
|
||||
await fetchTasks(); setShowForm(false);
|
||||
setFormData({ name: '', description: '', prompt: '', schedule_type: 'manual', schedule_value: '', enabled: true,
|
||||
agent_model: '', agent_tools: 'Bash,Read,Write,Edit', agent_system_prompt: '', agent_permission_mode: 'auto', agent_timeout: 300 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
}
|
||||
} catch(e) {}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleRunTask = async (taskId) => {
|
||||
try {
|
||||
await fetch(`/api/tasks/${taskId}/run`, { method: 'POST' });
|
||||
await fetchTasks();
|
||||
} catch (error) {
|
||||
console.error('Error running task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunTask = async (taskId) => { await fetch(`/api/tasks/${taskId}/run`, { method: 'POST' }); await fetchTasks(); };
|
||||
const handleDeleteTask = async (taskId) => {
|
||||
if (confirm('Delete this task?')) {
|
||||
try {
|
||||
await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' });
|
||||
await fetchTasks();
|
||||
setSelectedTask(null);
|
||||
} catch (error) {
|
||||
console.error('Error deleting task:', error);
|
||||
}
|
||||
}
|
||||
if (confirm('Delete this task?')) { await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' }); await fetchTasks(); setSelectedTask(null); }
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'running': return '#3498db';
|
||||
case 'completed': return '#27ae60';
|
||||
case 'failed': return '#e74c3c';
|
||||
default: return '#95a5a6';
|
||||
}
|
||||
};
|
||||
const getStatusColor = (s) => ({ running: '#3498db', completed: '#27ae60', failed: '#e74c3c' }[s] || '#95a5a6');
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<div className="header-content">
|
||||
<h1>Claude Persistent Agent</h1>
|
||||
<p>Scheduled task management & Claude Code runner</p>
|
||||
<p>Chat, schedule tasks & orchestrate agents</p>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<nav className="header-nav">
|
||||
<button className={`nav-btn ${activeView === 'tasks' ? 'active' : ''}`} onClick={() => setActiveView('tasks')}>Tasks</button>
|
||||
<button className={`nav-btn ${activeView === 'chat' ? 'active' : ''}`} onClick={() => setActiveView('chat')}>Chat</button>
|
||||
<button className={`nav-btn ${activeView === 'tasks' ? 'active' : ''}`} onClick={() => setActiveView('tasks')}>Agents</button>
|
||||
<button className={`nav-btn ${activeView === 'dashboard' ? 'active' : ''}`} onClick={() => setActiveView('dashboard')}>Dashboard</button>
|
||||
</nav>
|
||||
<div className="auth-badge">
|
||||
{authStatus?.status === 'logged_in' ? (
|
||||
<span className="auth-ok" title={authStatus.account || 'Authenticated'}>
|
||||
● {authStatus.account || `Authenticated (${authStatus.auth_method || 'token'})`}
|
||||
</span>
|
||||
<span className="auth-ok" title={authStatus.account || 'Authenticated'}>● {authStatus.account || 'Authenticated'}</span>
|
||||
) : authStatus?.has_saved_token ? (
|
||||
<span className="auth-ok auth-token-saved" title="Token saved">
|
||||
● Token saved ({authStatus.token_type === 'api_key' ? 'API Key' : 'OAuth'})
|
||||
</span>
|
||||
<span className="auth-ok auth-token-saved" title="Token saved">● Token saved</span>
|
||||
) : (
|
||||
<span className="auth-disconnected-badge" onClick={() => setActiveView('dashboard')} style={{cursor:'pointer'}}>
|
||||
⚠ Not authenticated
|
||||
</span>
|
||||
<span className="auth-disconnected-badge" onClick={() => setActiveView('dashboard')} style={{cursor:'pointer'}}>⚠ Not authenticated</span>
|
||||
)}
|
||||
</div>
|
||||
{systemInfo && (
|
||||
<div className="system-status">
|
||||
<span className={`status-indicator ${systemInfo.scheduler_running ? 'running' : 'stopped'}`}></span>
|
||||
<span>{systemInfo.task_count} tasks · {systemInfo.total_runs || 0} runs</span>
|
||||
<span>{systemInfo.task_count} agents · {systemInfo.total_runs || 0} runs</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
{activeView === 'dashboard' && (
|
||||
<div className="dashboard-view">
|
||||
<h2 className="dashboard-title">System Dashboard</h2>
|
||||
<div className="dashboard-grid">
|
||||
{systemInfo && (
|
||||
<>
|
||||
<div className="dash-card"><div className="dash-card-icon">📋</div><div className="dash-card-value">{systemInfo.task_count}</div><div className="dash-card-label">Total Tasks</div></div>
|
||||
<div className="dash-card"><div className="dash-card-icon">✅</div><div className="dash-card-value">{systemInfo.completed_runs || 0}</div><div className="dash-card-label">Completed Runs</div></div>
|
||||
<div className="dash-card"><div className="dash-card-icon">❌</div><div className="dash-card-value">{systemInfo.failed_runs || 0}</div><div className="dash-card-label">Failed Runs</div></div>
|
||||
<div className="dash-card"><div className="dash-card-icon">⚡</div><div className="dash-card-value">{systemInfo.running_runs || 0}</div><div className="dash-card-label">Currently Running</div></div>
|
||||
<div className="dash-card"><div className="dash-card-icon">🔄</div><div className="dash-card-value">{systemInfo.total_runs || 0}</div><div className="dash-card-label">Total Runs</div></div>
|
||||
<div className="dash-card"><div className="dash-card-icon">{systemInfo.scheduler_running ? '🟢' : '🔴'}</div><div className="dash-card-value">{systemInfo.scheduler_running ? 'Active' : 'Stopped'}</div><div className="dash-card-label">Scheduler</div></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auth Section */}
|
||||
<div className="dashboard-section">
|
||||
<h3>Claude Authentication</h3>
|
||||
<div className="auth-panel">
|
||||
{authStatus?.status === 'logged_in' ? (
|
||||
<div className="auth-connected">
|
||||
<span className="auth-icon">✅</span>
|
||||
<div>
|
||||
<div className="auth-title">Connected to Claude</div>
|
||||
<div className="auth-sub">{authStatus.account || 'Authenticated'}{authStatus.auth_method && ` (${authStatus.auth_method})`}</div>
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleLogout}>Log Out</button>
|
||||
{/* ===== CHAT VIEW ===== */}
|
||||
{activeView === 'chat' && (
|
||||
<div className="chat-view">
|
||||
<div className="chat-sidebar">
|
||||
<button className="btn btn-primary btn-sm chat-new-btn" onClick={startNewChat}>+ New Chat</button>
|
||||
<div className="chat-model-select">
|
||||
<select value={chatModel} onChange={e => setChatModel(e.target.value)}>
|
||||
<option value="">Default Model</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="haiku">Haiku</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="chat-sessions-list">
|
||||
<button className="btn btn-secondary btn-xs" onClick={fetchChatSessions} style={{width:'100%',marginBottom:'0.5rem'}}>Load History</button>
|
||||
{chatSessions.map(s => (
|
||||
<div key={s.session_id} className={`chat-session-item ${s.session_id === chatSessionId ? 'active' : ''}`}
|
||||
onClick={() => loadChatSession(s.session_id)}>
|
||||
<div className="chat-session-preview">{(s.first_message || 'Chat').substring(0, 40)}</div>
|
||||
<div className="chat-session-meta">{s.message_count} msgs · {new Date(s.last_message).toLocaleDateString()}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="auth-token-panel">
|
||||
<div className="auth-token-header">
|
||||
<span className="auth-icon">🔐</span>
|
||||
<div>
|
||||
<div className="auth-title">{authStatus?.has_saved_token ? 'Token Saved — Update or Replace' : 'Authenticate with Token'}</div>
|
||||
<div className="auth-sub">Two options to authenticate:</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="auth-methods">
|
||||
<div className="auth-method">
|
||||
<div className="auth-method-title">Option 1: Setup Token (Claude Max/Pro)</div>
|
||||
<div className="auth-method-desc">Run this on TrueNAS to generate a long-lived token:</div>
|
||||
<code className="auth-cmd">docker exec -it claude-persistent-agent claude setup-token</code>
|
||||
<div className="auth-method-desc">Then paste the token (starts with <code>sk-ant-oat</code>) below.</div>
|
||||
</div>
|
||||
<div className="auth-method">
|
||||
<div className="auth-method-title">Option 2: API Key (Console billing)</div>
|
||||
<div className="auth-method-desc">
|
||||
Get an API key from <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noreferrer">console.anthropic.com</a> and paste it below.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="auth-token-form" onSubmit={handleSubmitToken}>
|
||||
<div className="auth-token-type-row">
|
||||
<label><input type="radio" name="token_type" value="oauth_token" checked={tokenType === 'oauth_token'} onChange={() => setTokenType('oauth_token')} /> Setup Token (Max/Pro)</label>
|
||||
<label><input type="radio" name="token_type" value="api_key" checked={tokenType === 'api_key'} onChange={() => setTokenType('api_key')} /> API Key</label>
|
||||
</div>
|
||||
<div className="auth-token-input-row">
|
||||
<input type="password" placeholder={tokenType === 'oauth_token' ? 'sk-ant-oat01-...' : 'sk-ant-api03-...'} value={authToken} onChange={e => setAuthToken(e.target.value)} className="auth-token-input" autoComplete="off" />
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={tokenSubmitting || !authToken.trim()}>{tokenSubmitting ? 'Saving...' : 'Save Token'}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{authStatus?.has_saved_token && (
|
||||
<div className="auth-saved-info">
|
||||
Saved: {authStatus.token_type === 'api_key' ? 'API Key' : 'OAuth Token'}
|
||||
<button className="btn btn-secondary btn-xs" onClick={handleLogout}>Clear Token</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<div className="chat-ws-status">
|
||||
<span className={`ws-dot ${wsConnected ? 'connected' : 'disconnected'}`}></span>
|
||||
{wsConnected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MCP Section */}
|
||||
<div className="dashboard-section">
|
||||
<h3>MCP Servers</h3>
|
||||
<div className="mcp-panel">
|
||||
{mcpServers?.servers?.length > 0 ? (
|
||||
<div className="mcp-list">
|
||||
{mcpServers.servers.map((srv, i) => (
|
||||
<div key={i} className="mcp-row">
|
||||
<span className="mcp-status-dot connected"></span>
|
||||
<span className="mcp-name">{srv.name || srv}</span>
|
||||
<span className="mcp-details">{srv.details || srv.url || srv.type || ''}</span>
|
||||
<button className="btn-icon" onClick={() => handleRemoveMcp(srv.name || srv)} title="Remove">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mcp-empty">
|
||||
<p>No MCP servers configured.</p>
|
||||
{mcpServers?.raw && <pre className="mcp-raw">{mcpServers.raw}</pre>}
|
||||
<div className="chat-main">
|
||||
<div className="chat-messages">
|
||||
{chatMessages.length === 0 && (
|
||||
<div className="chat-empty">
|
||||
<div className="chat-empty-icon">💬</div>
|
||||
<h3>Chat with Claude</h3>
|
||||
<p>Send a message to start a conversation with the Claude Code instance running on your server.</p>
|
||||
<p className="chat-empty-hint">Claude has access to Bash, file system, and any MCP servers you've configured.</p>
|
||||
</div>
|
||||
)}
|
||||
{showAddMcp ? (
|
||||
<form className="mcp-add-form" onSubmit={handleAddMcp}>
|
||||
<div className="mcp-form-row">
|
||||
<input type="text" placeholder="Server name" value={mcpForm.name} onChange={e => setMcpForm({...mcpForm, name: e.target.value})} required />
|
||||
<select value={mcpForm.server_type} onChange={e => setMcpForm({...mcpForm, server_type: e.target.value})}>
|
||||
<option value="sse">SSE (URL)</option>
|
||||
<option value="stdio">Stdio (Command)</option>
|
||||
</select>
|
||||
{chatMessages.map((msg, i) => (
|
||||
<div key={i} className={`chat-msg chat-msg-${msg.role}`}>
|
||||
<div className="chat-msg-avatar">
|
||||
{msg.role === 'user' ? '👤' : msg.role === 'error' ? '⚠️' : '🤖'}
|
||||
</div>
|
||||
{mcpForm.server_type === 'sse' ? (
|
||||
<input type="url" placeholder="https://mcp-server-url/mcp" value={mcpForm.url} onChange={e => setMcpForm({...mcpForm, url: e.target.value})} required />
|
||||
) : (
|
||||
<input type="text" placeholder="Command (e.g. npx mcp-server)" value={mcpForm.command} onChange={e => setMcpForm({...mcpForm, command: e.target.value})} required />
|
||||
)}
|
||||
<div className="mcp-form-actions">
|
||||
<button type="submit" className="btn btn-primary btn-sm">Add Server</button>
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setShowAddMcp(false)}>Cancel</button>
|
||||
<div className="chat-msg-content">
|
||||
<pre className="chat-msg-text">{msg.content}{msg.streaming ? '▊' : ''}</pre>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button className="btn btn-secondary btn-sm mcp-add-btn" onClick={() => setShowAddMcp(true)}>+ Add MCP Server</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Section */}
|
||||
<div className="dashboard-section">
|
||||
<h3>Claude API Usage</h3>
|
||||
<div className="usage-grid">
|
||||
{usageStats ? (
|
||||
<>
|
||||
<div className="usage-row"><span className="usage-label">Claude Runs (all time)</span><span className="usage-value">{usageStats.claude_runs_total ?? '—'}</span></div>
|
||||
<div className="usage-row"><span className="usage-label">Active Sessions</span><span className="usage-value">{usageStats.session_count ?? '—'}</span></div>
|
||||
<div className="usage-row"><span className="usage-label">First Run</span><span className="usage-value">{usageStats.first_run ? new Date(usageStats.first_run).toLocaleString() : '—'}</span></div>
|
||||
<div className="usage-row"><span className="usage-label">Last Run</span><span className="usage-value">{usageStats.last_run ? new Date(usageStats.last_run).toLocaleString() : '—'}</span></div>
|
||||
<div className="usage-row highlight"><span className="usage-label">🔄 Next Monthly Reset</span><span className="usage-value">{usageStats.next_reset ? new Date(usageStats.next_reset).toLocaleDateString() : '—'}</span></div>
|
||||
<div className="usage-row highlight"><span className="usage-label">⏳ Days Until Reset</span><span className="usage-value">{usageStats.days_until_reset ?? '—'}</span></div>
|
||||
</>
|
||||
) : (
|
||||
<div className="usage-loading">Loading usage stats...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Breakdown */}
|
||||
<div className="dashboard-section">
|
||||
<h3>Task Breakdown</h3>
|
||||
<div className="task-breakdown">
|
||||
{tasks.length === 0 ? (
|
||||
<p className="empty-msg">No tasks yet. Create one in the Tasks view.</p>
|
||||
) : (
|
||||
tasks.map(t => (
|
||||
<div key={t.id} className="breakdown-row">
|
||||
<span className="breakdown-name">{t.name}</span>
|
||||
<span className="breakdown-type">{t.schedule_type}</span>
|
||||
<span className={`breakdown-status status-${t.status}`}>{t.status}</span>
|
||||
<span className="breakdown-last">{t.last_run ? new Date(t.last_run).toLocaleString() : 'never'}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div className="chat-input-area">
|
||||
<textarea
|
||||
className="chat-input"
|
||||
value={chatInput}
|
||||
onChange={e => setChatInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); } }}
|
||||
placeholder={chatSending ? 'Claude is thinking...' : 'Send a message... (Enter to send, Shift+Enter for newline)'}
|
||||
disabled={chatSending}
|
||||
rows={2}
|
||||
/>
|
||||
<button className="chat-send-btn" onClick={sendChatMessage} disabled={chatSending || !chatInput.trim()}>
|
||||
{chatSending ? '⏳' : '→'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== TASKS/AGENTS VIEW ===== */}
|
||||
{activeView === 'tasks' && (<>
|
||||
<aside className="sidebar">
|
||||
<button className="btn btn-primary btn-create" onClick={() => setShowForm(true)}>+ New Task</button>
|
||||
<button className="btn btn-primary btn-create" onClick={() => setShowForm(true)}>+ New Agent Task</button>
|
||||
<div className="tasks-list">
|
||||
<h2>Tasks</h2>
|
||||
<h2>Agent Tasks</h2>
|
||||
{tasks.length === 0 ? (
|
||||
<p className="empty-state">No tasks yet. Create one to get started.</p>
|
||||
<p className="empty-state">No agent tasks yet. Create one to get started.</p>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<div key={task.id} className={`task-item ${selectedTask?.id === task.id ? 'active' : ''}`} onClick={() => setSelectedTask(task)}>
|
||||
|
|
@ -424,8 +315,11 @@ const App = () => {
|
|||
<h3>{task.name}</h3>
|
||||
<span className="status-badge" style={{ backgroundColor: getStatusColor(task.status) }}>{task.status}</span>
|
||||
</div>
|
||||
<p className="task-schedule">{task.schedule_type === 'manual' ? 'Manual' : `${task.schedule_type}: ${task.schedule_value}`}</p>
|
||||
{task.last_run && <p className="task-last-run">Last run: {new Date(task.last_run).toLocaleString()}</p>}
|
||||
<p className="task-schedule">
|
||||
{task.schedule_type === 'manual' ? '⚡ Manual' : `🔄 ${task.schedule_type}: ${task.schedule_value}`}
|
||||
{task.agent_model && ` · ${task.agent_model}`}
|
||||
</p>
|
||||
{task.last_run && <p className="task-last-run">Last: {new Date(task.last_run).toLocaleString()}</p>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
|
@ -435,24 +329,26 @@ const App = () => {
|
|||
<section className="content">
|
||||
{showForm ? (
|
||||
<div className="form-container">
|
||||
<h2>Create New Task</h2>
|
||||
<h2>Create Agent Task</h2>
|
||||
<form onSubmit={handleCreateTask} className="task-form">
|
||||
<div className="form-group">
|
||||
<label>Task Name</label>
|
||||
<input type="text" required value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="e.g., Daily Backup" />
|
||||
<input type="text" required value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} placeholder="e.g., Daily Code Review" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<textarea value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} placeholder="Optional description" rows={2} />
|
||||
<textarea value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})} placeholder="What does this agent do?" rows={2} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Claude Prompt</label>
|
||||
<textarea required value={formData.prompt} onChange={(e) => setFormData({ ...formData, prompt: e.target.value })} placeholder="What should Claude do? Be specific..." rows={6} />
|
||||
<label>Agent Prompt</label>
|
||||
<textarea required value={formData.prompt} onChange={e => setFormData({...formData, prompt: e.target.value})} placeholder="Instructions for the Claude agent..." rows={6} />
|
||||
</div>
|
||||
|
||||
<div className="form-section-title">Schedule</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Schedule Type</label>
|
||||
<select value={formData.schedule_type} onChange={(e) => setFormData({ ...formData, schedule_type: e.target.value })}>
|
||||
<select value={formData.schedule_type} onChange={e => setFormData({...formData, schedule_type: e.target.value})}>
|
||||
<option value="manual">Manual Only</option>
|
||||
<option value="recurring">Recurring (Cron)</option>
|
||||
<option value="once">Once (Datetime)</option>
|
||||
|
|
@ -461,22 +357,55 @@ const App = () => {
|
|||
{formData.schedule_type === 'recurring' && (
|
||||
<div className="form-group">
|
||||
<label>Cron Expression</label>
|
||||
<input type="text" value={formData.schedule_value} onChange={(e) => setFormData({ ...formData, schedule_value: e.target.value })} placeholder="0 9 * * * (daily at 9am)" />
|
||||
<input type="text" value={formData.schedule_value} onChange={e => setFormData({...formData, schedule_value: e.target.value})} placeholder="0 9 * * *" />
|
||||
</div>
|
||||
)}
|
||||
{formData.schedule_type === 'once' && (
|
||||
<div className="form-group">
|
||||
<label>Run At</label>
|
||||
<input type="datetime-local" value={formData.schedule_value} onChange={(e) => setFormData({ ...formData, schedule_value: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group"><label>Run At</label><input type="datetime-local" value={formData.schedule_value} onChange={e => setFormData({...formData, schedule_value: e.target.value})} /></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-section-title">Agent Configuration</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Model</label>
|
||||
<select value={formData.agent_model} onChange={e => setFormData({...formData, agent_model: e.target.value})}>
|
||||
<option value="">Default</option>
|
||||
<option value="sonnet">Sonnet (fast)</option>
|
||||
<option value="opus">Opus (powerful)</option>
|
||||
<option value="haiku">Haiku (lightweight)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Permission Mode</label>
|
||||
<select value={formData.agent_permission_mode} onChange={e => setFormData({...formData, agent_permission_mode: e.target.value})}>
|
||||
<option value="auto">Auto (full access)</option>
|
||||
<option value="acceptEdits">Accept Edits Only</option>
|
||||
<option value="plan">Plan Mode (read-only)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Allowed Tools</label>
|
||||
<input type="text" value={formData.agent_tools} onChange={e => setFormData({...formData, agent_tools: e.target.value})} placeholder="Bash,Read,Write,Edit" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Timeout (seconds)</label>
|
||||
<input type="number" value={formData.agent_timeout} onChange={e => setFormData({...formData, agent_timeout: parseInt(e.target.value) || 300})} min={30} max={3600} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Custom System Prompt (optional)</label>
|
||||
<textarea value={formData.agent_system_prompt} onChange={e => setFormData({...formData, agent_system_prompt: e.target.value})} placeholder="Override the default system prompt..." rows={3} />
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox">
|
||||
<input type="checkbox" checked={formData.enabled} onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })} />
|
||||
<input type="checkbox" checked={formData.enabled} onChange={e => setFormData({...formData, enabled: e.target.checked})} />
|
||||
<label>Enabled</label>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>{loading ? 'Creating...' : 'Create Task'}</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>{loading ? 'Creating...' : 'Create Agent Task'}</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowForm(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -491,23 +420,134 @@ const App = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div className="task-meta">
|
||||
<div className="meta-item"><span className="label">Status:</span><span className="value" style={{ color: getStatusColor(selectedTask.status) }}>{selectedTask.status}</span></div>
|
||||
<div className="meta-item"><span className="label">Schedule:</span><span className="value">{selectedTask.schedule_type === 'manual' ? 'Manual' : `${selectedTask.schedule_type}: ${selectedTask.schedule_value}`}</span></div>
|
||||
<div className="meta-item"><span className="label">Created:</span><span className="value">{new Date(selectedTask.created_at).toLocaleString()}</span></div>
|
||||
{selectedTask.last_run && <div className="meta-item"><span className="label">Last Run:</span><span className="value">{new Date(selectedTask.last_run).toLocaleString()}</span></div>}
|
||||
<div className="meta-item"><span className="label">Status</span><span className="value" style={{color: getStatusColor(selectedTask.status)}}>{selectedTask.status}</span></div>
|
||||
<div className="meta-item"><span className="label">Schedule</span><span className="value">{selectedTask.schedule_type === 'manual' ? 'Manual' : `${selectedTask.schedule_type}: ${selectedTask.schedule_value}`}</span></div>
|
||||
<div className="meta-item"><span className="label">Model</span><span className="value">{selectedTask.agent_model || 'Default'}</span></div>
|
||||
<div className="meta-item"><span className="label">Tools</span><span className="value">{selectedTask.agent_tools || 'Bash,Read,Write,Edit'}</span></div>
|
||||
<div className="meta-item"><span className="label">Permission</span><span className="value">{selectedTask.agent_permission_mode || 'auto'}</span></div>
|
||||
<div className="meta-item"><span className="label">Timeout</span><span className="value">{selectedTask.agent_timeout || 300}s</span></div>
|
||||
<div className="meta-item"><span className="label">Created</span><span className="value">{new Date(selectedTask.created_at).toLocaleString()}</span></div>
|
||||
{selectedTask.last_run && <div className="meta-item"><span className="label">Last Run</span><span className="value">{new Date(selectedTask.last_run).toLocaleString()}</span></div>}
|
||||
</div>
|
||||
{selectedTask.description && <div className="task-section"><h3>Description</h3><p>{selectedTask.description}</p></div>}
|
||||
<div className="task-section"><h3>Prompt</h3><pre className="prompt-display">{selectedTask.prompt}</pre></div>
|
||||
{selectedTask.agent_system_prompt && <div className="task-section"><h3>System Prompt</h3><pre className="prompt-display">{selectedTask.agent_system_prompt}</pre></div>}
|
||||
<div className="task-section"><h3>Agent Prompt</h3><pre className="prompt-display">{selectedTask.prompt}</pre></div>
|
||||
<TaskRuns taskId={selectedTask.id} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state-main">
|
||||
<h2>Select a task to view details</h2>
|
||||
<h2>Select an agent task to view details</h2>
|
||||
<p>Or create a new one to get started</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>)}
|
||||
|
||||
{/* ===== DASHBOARD VIEW ===== */}
|
||||
{activeView === 'dashboard' && (
|
||||
<div className="dashboard-view">
|
||||
<h2 className="dashboard-title">System Dashboard</h2>
|
||||
<div className="dashboard-grid">
|
||||
{systemInfo && (<>
|
||||
<div className="dash-card"><div className="dash-card-icon">📋</div><div className="dash-card-value">{systemInfo.task_count}</div><div className="dash-card-label">Agent Tasks</div></div>
|
||||
<div className="dash-card"><div className="dash-card-icon">✅</div><div className="dash-card-value">{systemInfo.completed_runs || 0}</div><div className="dash-card-label">Completed</div></div>
|
||||
<div className="dash-card"><div className="dash-card-icon">❌</div><div className="dash-card-value">{systemInfo.failed_runs || 0}</div><div className="dash-card-label">Failed</div></div>
|
||||
<div className="dash-card"><div className="dash-card-icon">⚡</div><div className="dash-card-value">{systemInfo.running_runs || 0}</div><div className="dash-card-label">Running</div></div>
|
||||
<div className="dash-card"><div className="dash-card-icon">💬</div><div className="dash-card-value">{systemInfo.active_chat_sessions || 0}</div><div className="dash-card-label">Active Chats</div></div>
|
||||
<div className="dash-card"><div className="dash-card-icon">{systemInfo.scheduler_running ? '🟢' : '🔴'}</div><div className="dash-card-value">{systemInfo.scheduler_running ? 'Active' : 'Stopped'}</div><div className="dash-card-label">Scheduler</div></div>
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{/* Auth */}
|
||||
<div className="dashboard-section">
|
||||
<h3>Claude Authentication</h3>
|
||||
<div className="auth-panel">
|
||||
{authStatus?.status === 'logged_in' ? (
|
||||
<div className="auth-connected">
|
||||
<span className="auth-icon">✅</span>
|
||||
<div><div className="auth-title">Connected to Claude</div><div className="auth-sub">{authStatus.account || 'Authenticated'}{authStatus.auth_method && ` (${authStatus.auth_method})`}</div></div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleLogout}>Log Out</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="auth-token-panel">
|
||||
<div className="auth-token-header">
|
||||
<span className="auth-icon">🔐</span>
|
||||
<div><div className="auth-title">{authStatus?.has_saved_token ? 'Update Token' : 'Authenticate'}</div><div className="auth-sub">Two options:</div></div>
|
||||
</div>
|
||||
<div className="auth-methods">
|
||||
<div className="auth-method">
|
||||
<div className="auth-method-title">Option 1: Setup Token (Claude Max/Pro)</div>
|
||||
<div className="auth-method-desc">Run on TrueNAS shell:</div>
|
||||
<code className="auth-cmd">docker exec -it claude-persistent-agent claude setup-token</code>
|
||||
<div className="auth-method-desc">Paste the <code>sk-ant-oat01-...</code> token below.</div>
|
||||
</div>
|
||||
<div className="auth-method">
|
||||
<div className="auth-method-title">Option 2: API Key</div>
|
||||
<div className="auth-method-desc">From <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noreferrer">console.anthropic.com</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<form className="auth-token-form" onSubmit={handleSubmitToken}>
|
||||
<div className="auth-token-type-row">
|
||||
<label><input type="radio" name="tt" value="oauth_token" checked={tokenType === 'oauth_token'} onChange={() => setTokenType('oauth_token')} /> Setup Token</label>
|
||||
<label><input type="radio" name="tt" value="api_key" checked={tokenType === 'api_key'} onChange={() => setTokenType('api_key')} /> API Key</label>
|
||||
</div>
|
||||
<div className="auth-token-input-row">
|
||||
<input type="password" placeholder={tokenType === 'oauth_token' ? 'sk-ant-oat01-...' : 'sk-ant-api03-...'} value={authToken} onChange={e => setAuthToken(e.target.value)} className="auth-token-input" autoComplete="off" />
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={tokenSubmitting || !authToken.trim()}>{tokenSubmitting ? 'Saving...' : 'Save'}</button>
|
||||
</div>
|
||||
</form>
|
||||
{authStatus?.has_saved_token && (
|
||||
<div className="auth-saved-info">Saved: {authStatus.token_type === 'api_key' ? 'API Key' : 'OAuth Token'} <button className="btn btn-secondary btn-xs" onClick={handleLogout}>Clear</button></div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MCP */}
|
||||
<div className="dashboard-section">
|
||||
<h3>MCP Servers</h3>
|
||||
<div className="mcp-panel">
|
||||
{mcpServers?.servers?.length > 0 ? (
|
||||
<div className="mcp-list">
|
||||
{mcpServers.servers.map((srv, i) => (
|
||||
<div key={i} className="mcp-row">
|
||||
<span className="mcp-status-dot connected"></span>
|
||||
<span className="mcp-name">{srv.name || srv}</span>
|
||||
<span className="mcp-details">{srv.details || srv.url || ''}</span>
|
||||
<button className="btn-icon" onClick={() => handleRemoveMcp(srv.name || srv)} title="Remove">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="mcp-empty"><p>No MCP servers configured.</p></div>}
|
||||
{showAddMcp ? (
|
||||
<form className="mcp-add-form" onSubmit={handleAddMcp}>
|
||||
<div className="mcp-form-row">
|
||||
<input type="text" placeholder="Server name" value={mcpForm.name} onChange={e => setMcpForm({...mcpForm, name: e.target.value})} required />
|
||||
<select value={mcpForm.server_type} onChange={e => setMcpForm({...mcpForm, server_type: e.target.value})}><option value="sse">SSE</option><option value="stdio">Stdio</option></select>
|
||||
</div>
|
||||
{mcpForm.server_type === 'sse' ? <input type="url" placeholder="https://..." value={mcpForm.url} onChange={e => setMcpForm({...mcpForm, url: e.target.value})} required /> :
|
||||
<input type="text" placeholder="Command" value={mcpForm.command} onChange={e => setMcpForm({...mcpForm, command: e.target.value})} required />}
|
||||
<div className="mcp-form-actions"><button type="submit" className="btn btn-primary btn-sm">Add</button><button type="button" className="btn btn-secondary btn-sm" onClick={() => setShowAddMcp(false)}>Cancel</button></div>
|
||||
</form>
|
||||
) : <button className="btn btn-secondary btn-sm mcp-add-btn" onClick={() => setShowAddMcp(true)}>+ Add MCP Server</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage */}
|
||||
<div className="dashboard-section">
|
||||
<h3>Usage</h3>
|
||||
<div className="usage-grid">
|
||||
{usageStats ? (<>
|
||||
<div className="usage-row"><span className="usage-label">Total Runs</span><span className="usage-value">{usageStats.claude_runs_total ?? '—'}</span></div>
|
||||
<div className="usage-row"><span className="usage-label">Sessions</span><span className="usage-value">{usageStats.session_count ?? '—'}</span></div>
|
||||
<div className="usage-row"><span className="usage-label">Next Reset</span><span className="usage-value">{usageStats.next_reset ? new Date(usageStats.next_reset).toLocaleDateString() : '—'}</span></div>
|
||||
<div className="usage-row"><span className="usage-label">Days Until Reset</span><span className="usage-value">{usageStats.days_until_reset ?? '—'}</span></div>
|
||||
</>) : <div className="usage-loading">Loading...</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -515,30 +555,19 @@ const App = () => {
|
|||
|
||||
const TaskRuns = ({ taskId }) => {
|
||||
const [runs, setRuns] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRuns = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${taskId}/runs`);
|
||||
const data = await response.json();
|
||||
setRuns(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching runs:', error);
|
||||
}
|
||||
};
|
||||
fetchRuns();
|
||||
const interval = setInterval(fetchRuns, 3000);
|
||||
return () => clearInterval(interval);
|
||||
const fetch_ = async () => { try { const r = await fetch(`/api/tasks/${taskId}/runs`); setRuns(await r.json()); } catch(e) {} };
|
||||
fetch_();
|
||||
const i = setInterval(fetch_, 3000);
|
||||
return () => clearInterval(i);
|
||||
}, [taskId]);
|
||||
|
||||
return (
|
||||
<div className="task-section">
|
||||
<h3>Run History</h3>
|
||||
{runs.length === 0 ? (
|
||||
<p className="empty">No runs yet</p>
|
||||
) : (
|
||||
{runs.length === 0 ? <p className="empty">No runs yet</p> : (
|
||||
<div className="runs-list">
|
||||
{runs.map((run) => (
|
||||
{runs.map(run => (
|
||||
<div key={run.run_id} className="run-item">
|
||||
<div className="run-header">
|
||||
<span className={`run-status ${run.status}`}>{run.status}</span>
|
||||
|
|
|
|||
Reference in a new issue