This repository has been archived on 2026-04-05. You can view files and clone it, but cannot push or open issues or pull requests.
claude-persistent-agent/frontend/src/App.jsx

558 lines
26 KiB
React
Raw Normal View History

2026-04-04 22:40:51 -04:00
import React, { useState, useEffect } from 'react';
import './App.css';
const App = () => {
const [tasks, setTasks] = useState([]);
const [selectedTask, setSelectedTask] = useState(null);
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
prompt: '',
schedule_type: 'manual',
schedule_value: '',
enabled: true
});
const [systemInfo, setSystemInfo] = useState(null);
2026-04-04 22:46:16 -04:00
const [usageStats, setUsageStats] = useState(null);
2026-04-04 22:51:39 -04:00
const [authStatus, setAuthStatus] = useState(null);
2026-04-04 23:50:16 -04:00
const [mcpServers, setMcpServers] = useState(null);
const [showAddMcp, setShowAddMcp] = useState(false);
const [mcpForm, setMcpForm] = useState({ name: '', server_type: 'sse', url: '', command: '' });
2026-04-04 22:40:51 -04:00
const [loading, setLoading] = useState(false);
const [activeView, setActiveView] = useState('tasks');
// Auth token state
const [authToken, setAuthToken] = useState('');
const [tokenType, setTokenType] = useState('oauth_token');
const [tokenSubmitting, setTokenSubmitting] = useState(false);
2026-04-04 22:40:51 -04:00
useEffect(() => {
fetchTasks();
fetchSystemInfo();
2026-04-04 22:46:16 -04:00
fetchUsageStats();
2026-04-04 22:51:39 -04:00
fetchAuthStatus();
2026-04-04 23:50:16 -04:00
fetchMcpServers();
2026-04-04 22:40:51 -04:00
const interval = setInterval(() => {
fetchTasks();
fetchSystemInfo();
2026-04-04 22:46:16 -04:00
fetchUsageStats();
2026-04-04 22:51:39 -04:00
fetchAuthStatus();
2026-04-04 23:50:16 -04:00
fetchMcpServers();
2026-04-04 22:46:16 -04:00
}, 10000);
2026-04-04 22:40:51 -04:00
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);
}
};
const fetchSystemInfo = async () => {
try {
const response = await fetch('/api/system/info');
const data = await response.json();
setSystemInfo(data);
} catch (error) {
console.error('Error fetching system info:', error);
}
};
2026-04-04 22:46:16 -04:00
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);
}
};
2026-04-04 22:51:39 -04:00
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);
}
};
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' },
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');
}
setTokenSubmitting(false);
};
2026-04-04 22:51:39 -04:00
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
fetchAuthStatus();
} catch (error) {
console.error('Error logging out:', error);
}
};
2026-04-04 23:50:16 -04:00
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 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);
}
};
2026-04-04 22:40:51 -04:00
const handleCreateTask = async (e) => {
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 });
2026-04-04 22:40:51 -04:00
}
} catch (error) {
console.error('Error creating task:', error);
}
setLoading(false);
};
const handleRunTask = async (taskId) => {
try {
await fetch(`/api/tasks/${taskId}/run`, { method: 'POST' });
2026-04-04 22:40:51 -04:00
await fetchTasks();
} catch (error) {
console.error('Error running task:', error);
}
};
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);
}
}
};
const getStatusColor = (status) => {
switch (status) {
case 'running': return '#3498db';
case 'completed': return '#27ae60';
case 'failed': return '#e74c3c';
default: return '#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>
</div>
2026-04-04 22:46:16 -04:00
<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 === 'dashboard' ? 'active' : ''}`} onClick={() => setActiveView('dashboard')}>Dashboard</button>
</nav>
2026-04-04 22:51:39 -04:00
<div className="auth-badge">
{authStatus?.status === 'logged_in' ? (
<span className="auth-ok" title={authStatus.account || 'Authenticated'}>
{authStatus.account || `Authenticated (${authStatus.auth_method || 'token'})`}
</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>
2026-04-04 22:51:39 -04:00
) : (
<span className="auth-disconnected-badge" onClick={() => setActiveView('dashboard')} style={{cursor:'pointer'}}>
Not authenticated
</span>
2026-04-04 22:51:39 -04:00
)}
</div>
2026-04-04 22:46:16 -04:00
{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>
</div>
)}
</div>
2026-04-04 22:40:51 -04:00
</header>
<main className="app-main">
2026-04-04 22:46:16 -04:00
{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>
2026-04-04 22:46:16 -04:00
</>
)}
</div>
{/* Auth Section */}
2026-04-04 22:51:39 -04:00
<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>
2026-04-04 22:51:39 -04:00
</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 ? 'Token Saved — Update or Replace' : 'Authenticate with Token'}</div>
<div className="auth-sub">Two options to authenticate:</div>
</div>
2026-04-04 22:51:39 -04:00
</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>
)}
2026-04-04 22:51:39 -04:00
</div>
)}
</div>
</div>
{/* MCP Section */}
2026-04-04 23:50:16 -04:00
<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>
)}
{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})}>
2026-04-04 23:50:16 -04:00
<option value="sse">SSE (URL)</option>
<option value="stdio">Stdio (Command)</option>
</select>
</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 />
2026-04-04 23:50:16 -04:00
) : (
<input type="text" placeholder="Command (e.g. npx mcp-server)" value={mcpForm.command} onChange={e => setMcpForm({...mcpForm, command: e.target.value})} required />
2026-04-04 23:50:16 -04:00
)}
<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>
</form>
) : (
<button className="btn btn-secondary btn-sm mcp-add-btn" onClick={() => setShowAddMcp(true)}>+ Add MCP Server</button>
)}
</div>
</div>
{/* Usage Section */}
2026-04-04 22:46:16 -04:00
<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>
2026-04-04 22:46:16 -04:00
</>
) : (
<div className="usage-loading">Loading usage stats...</div>
2026-04-04 22:46:16 -04:00
)}
</div>
</div>
{/* Task Breakdown */}
2026-04-04 22:46:16 -04:00
<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>
</div>
</div>
)}
2026-04-04 22:47:31 -04:00
{activeView === 'tasks' && (<>
2026-04-04 22:40:51 -04:00
<aside className="sidebar">
<button className="btn btn-primary btn-create" onClick={() => setShowForm(true)}>+ New Task</button>
2026-04-04 22:40:51 -04:00
<div className="tasks-list">
<h2>Tasks</h2>
{tasks.length === 0 ? (
<p className="empty-state">No 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)}>
2026-04-04 22:40:51 -04:00
<div className="task-item-header">
<h3>{task.name}</h3>
<span className="status-badge" style={{ backgroundColor: getStatusColor(task.status) }}>{task.status}</span>
2026-04-04 22:40:51 -04:00
</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>}
2026-04-04 22:40:51 -04:00
</div>
))
)}
</div>
</aside>
<section className="content">
{showForm ? (
<div className="form-container">
<h2>Create New 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" />
2026-04-04 22:40:51 -04:00
</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} />
2026-04-04 22:40:51 -04:00
</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} />
2026-04-04 22:40:51 -04:00
</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 })}>
2026-04-04 22:40:51 -04:00
<option value="manual">Manual Only</option>
<option value="recurring">Recurring (Cron)</option>
<option value="once">Once (Datetime)</option>
</select>
</div>
{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)" />
2026-04-04 22:40:51 -04:00
</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 })} />
2026-04-04 22:40:51 -04:00
</div>
)}
</div>
<div className="form-group checkbox">
<input type="checkbox" checked={formData.enabled} onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })} />
2026-04-04 22:40:51 -04:00
<label>Enabled</label>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={loading}>{loading ? 'Creating...' : 'Create Task'}</button>
<button type="button" className="btn btn-secondary" onClick={() => setShowForm(false)}>Cancel</button>
2026-04-04 22:40:51 -04:00
</div>
</form>
</div>
) : selectedTask ? (
<div className="task-detail">
<div className="task-detail-header">
<h2>{selectedTask.name}</h2>
<div className="task-actions">
<button className="btn btn-success" onClick={() => handleRunTask(selectedTask.id)}> Run Now</button>
<button className="btn btn-danger" onClick={() => handleDeleteTask(selectedTask.id)}>🗑 Delete</button>
2026-04-04 22:40:51 -04:00
</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>}
2026-04-04 22:40:51 -04:00
</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>
2026-04-04 22:40:51 -04:00
<TaskRuns taskId={selectedTask.id} />
</div>
) : (
<div className="empty-state-main">
<h2>Select a task to view details</h2>
<p>Or create a new one to get started</p>
</div>
)}
</section>
</>)}
2026-04-04 22:40:51 -04:00
</main>
</div>
);
};
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);
}, [taskId]);
return (
<div className="task-section">
<h3>Run History</h3>
{runs.length === 0 ? (
<p className="empty">No runs yet</p>
) : (
<div className="runs-list">
{runs.map((run) => (
<div key={run.run_id} className="run-item">
<div className="run-header">
<span className={`run-status ${run.status}`}>{run.status}</span>
<span className="run-time">{new Date(run.started_at).toLocaleString()}</span>
2026-04-04 22:40:51 -04:00
</div>
{run.output && <details><summary>Output</summary><pre>{run.output}</pre></details>}
{run.error && <details><summary>Error</summary><pre className="error">{run.error}</pre></details>}
2026-04-04 22:40:51 -04:00
</div>
))}
</div>
)}
</div>
);
};
export default App;