404 lines
13 KiB
React
404 lines
13 KiB
React
|
|
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 [loading, setLoading] = useState(false);
|
||
|
|
|
||
|
|
// Fetch tasks on component mount
|
||
|
|
useEffect(() => {
|
||
|
|
fetchTasks();
|
||
|
|
fetchSystemInfo();
|
||
|
|
const interval = setInterval(() => {
|
||
|
|
fetchTasks();
|
||
|
|
fetchSystemInfo();
|
||
|
|
}, 5000); // Refresh every 5 seconds
|
||
|
|
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 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>
|
||
|
|
{systemInfo && (
|
||
|
|
<div className="system-status">
|
||
|
|
<span className={`status-indicator ${systemInfo.scheduler_running ? 'running' : 'stopped'}`}></span>
|
||
|
|
<span>{systemInfo.task_count} tasks</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<main className="app-main">
|
||
|
|
<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>
|
||
|
|
</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;
|