605 lines
22 KiB
JavaScript
605 lines
22 KiB
JavaScript
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);
|
|
const [usageStats, setUsageStats] = useState(null);
|
|
const [authStatus, setAuthStatus] = useState(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [activeView, setActiveView] = useState('tasks'); // 'tasks' | 'dashboard'
|
|
|
|
// Fetch tasks on component mount
|
|
useEffect(() => {
|
|
fetchTasks();
|
|
fetchSystemInfo();
|
|
fetchUsageStats();
|
|
fetchAuthStatus();
|
|
const interval = setInterval(() => {
|
|
fetchTasks();
|
|
fetchSystemInfo();
|
|
fetchUsageStats();
|
|
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);
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
const handleLogin = async () => {
|
|
try {
|
|
const response = await fetch('/api/auth/login', { method: 'POST' });
|
|
const data = await response.json();
|
|
setAuthStatus(prev => ({ ...prev, ...data }));
|
|
if (data.auth_url) {
|
|
window.open(data.auth_url, '_blank');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error initiating login:', error);
|
|
}
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
await fetch('/api/auth/logout', { method: 'POST' });
|
|
fetchAuthStatus();
|
|
} catch (error) {
|
|
console.error('Error logging out:', error);
|
|
}
|
|
};
|
|
|
|
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
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating task:', error);
|
|
}
|
|
|
|
setLoading(false);
|
|
};
|
|
|
|
const handleRunTask = async (taskId) => {
|
|
try {
|
|
const response = await fetch(`/api/tasks/${taskId}/run`, {
|
|
method: 'POST'
|
|
});
|
|
const data = await response.json();
|
|
console.log('Task started:', data);
|
|
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>
|
|
<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>
|
|
<div className="auth-badge">
|
|
{authStatus?.status === 'logged_in' ? (
|
|
<span className="auth-ok" title={authStatus.account}>● {authStatus.account || 'Logged in'}</span>
|
|
) : authStatus?.status === 'pending' ? (
|
|
<span className="auth-pending">⏳ Awaiting login…</span>
|
|
) : (
|
|
<button className="auth-login-btn" onClick={handleLogin}>🔐 Login with Claude Max</button>
|
|
)}
|
|
</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>
|
|
</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 Ever</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>
|
|
|
|
<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 Max</div>
|
|
<div className="auth-sub">{authStatus.account}</div>
|
|
</div>
|
|
<button className="btn btn-secondary btn-sm" onClick={handleLogout}>Log Out</button>
|
|
</div>
|
|
) : authStatus?.status === 'pending' ? (
|
|
<div className="auth-pending-panel">
|
|
<span className="auth-icon">⏳</span>
|
|
<div>
|
|
<div className="auth-title">Waiting for browser login…</div>
|
|
{authStatus.auth_url && (
|
|
<a className="auth-url-link" href={authStatus.auth_url} target="_blank" rel="noreferrer">
|
|
Open login page →
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="auth-disconnected">
|
|
<span className="auth-icon">🔐</span>
|
|
<div>
|
|
<div className="auth-title">Not logged in</div>
|
|
<div className="auth-sub">Log in with your Claude Max account to run tasks</div>
|
|
</div>
|
|
<button className="btn btn-primary btn-sm" onClick={handleLogin}>Login with Claude Max</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
{usageStats.note && (
|
|
<div className="usage-note">{usageStats.note}</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="usage-loading">Loading usage stats…</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
)}
|
|
|
|
{activeView === 'tasks' && (<>
|
|
<aside className="sidebar">
|
|
<button
|
|
className="btn btn-primary btn-create"
|
|
onClick={() => setShowForm(true)}
|
|
>
|
|
+ New Task
|
|
</button>
|
|
|
|
<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)}
|
|
>
|
|
<div className="task-item-header">
|
|
<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>
|
|
)}
|
|
</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"
|
|
/>
|
|
</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}
|
|
/>
|
|
</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}
|
|
/>
|
|
</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 })}
|
|
>
|
|
<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)"
|
|
/>
|
|
</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>
|
|
|
|
<div className="form-group checkbox">
|
|
<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="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => setShowForm(false)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</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>
|
|
</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>
|
|
|
|
{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>
|
|
|
|
<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>
|
|
</>)} {/* end activeView === 'tasks' */}
|
|
</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>
|
|
</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>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App;
|