Feature: Task Scheduler - planned tasks with webhook support
This commit is contained in:
@@ -292,6 +292,29 @@ class ApiClient {
|
||||
return this.post('/api/v1/agents/request-update-all')
|
||||
}
|
||||
|
||||
// Tasks
|
||||
getTasks(params = {}) {
|
||||
const q = new URLSearchParams()
|
||||
if (params.limit) q.set('limit', params.limit)
|
||||
if (params.offset) q.set('offset', params.offset)
|
||||
if (params.agent_id) q.set('agent_id', params.agent_id)
|
||||
if (params.status) q.set('status', params.status)
|
||||
if (params.action) q.set('action', params.action)
|
||||
return this.get(`/api/v1/tasks?${q.toString()}`)
|
||||
}
|
||||
|
||||
createTask(data) {
|
||||
return this.post('/api/v1/tasks', data)
|
||||
}
|
||||
|
||||
cancelTask(id) {
|
||||
return this.post(`/api/v1/tasks/${id}/cancel`)
|
||||
}
|
||||
|
||||
deleteTask(id) {
|
||||
return this.del(`/api/v1/tasks/${id}`)
|
||||
}
|
||||
|
||||
// Installer
|
||||
getInstallerInfo() {
|
||||
return this.get('/api/v1/installer')
|
||||
|
||||
@@ -5,7 +5,7 @@ import { copyToClipboard } from '../utils/clipboard'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import {
|
||||
X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route,
|
||||
Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone,
|
||||
Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, Ban,
|
||||
} from 'lucide-react'
|
||||
|
||||
const allTabs = [
|
||||
@@ -20,6 +20,7 @@ const allTabs = [
|
||||
{ id: 'certs', label: 'Zertifikate', platforms: ['freebsd'] },
|
||||
{ id: 'updates', label: 'Updates', platforms: ['freebsd'] },
|
||||
{ id: 'security', label: 'Sicherheit', platforms: ['freebsd'] },
|
||||
{ id: 'tasks', label: 'Aufgaben', platforms: ['freebsd', 'linux'] },
|
||||
{ id: 'backups', label: 'Backups' },
|
||||
{ id: 'agent', label: 'Agent' },
|
||||
]
|
||||
@@ -129,6 +130,8 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {
|
||||
<UpdatesTab agentId={agentId} />
|
||||
) : tab === 'security' ? (
|
||||
<SecurityTab sys={sys} />
|
||||
) : tab === 'tasks' ? (
|
||||
<TasksTab agentId={agentId} />
|
||||
) : tab === 'backups' ? (
|
||||
platform === 'linux' ? <PVEBackupsTab sys={sys} /> : <BackupsTab agentId={agentId} />
|
||||
) : tab === 'agent' ? (
|
||||
@@ -1494,6 +1497,236 @@ function SecurityTab({ sys }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TasksTab({ agentId }) {
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [newTask, setNewTask] = useState({ action: 'backup', scheduled_at: '', webhook_url: '' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const loadTasks = () => {
|
||||
api.getTasks({ agent_id: agentId, limit: 50 })
|
||||
.then(resp => {
|
||||
setTasks(resp.data || [])
|
||||
setTotal(resp.total || 0)
|
||||
})
|
||||
.catch(() => setTasks([]))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { loadTasks() }, [agentId])
|
||||
|
||||
// Auto-refresh every 15s
|
||||
useEffect(() => {
|
||||
const iv = setInterval(loadTasks, 15000)
|
||||
return () => clearInterval(iv)
|
||||
}, [agentId])
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = {
|
||||
agent_id: agentId,
|
||||
action: newTask.action,
|
||||
}
|
||||
if (newTask.scheduled_at) {
|
||||
// Convert datetime-local to RFC3339
|
||||
const d = new Date(newTask.scheduled_at)
|
||||
data.scheduled_at = d.toISOString()
|
||||
}
|
||||
if (newTask.webhook_url) data.webhook_url = newTask.webhook_url
|
||||
await api.createTask(data)
|
||||
setShowDialog(false)
|
||||
setNewTask({ action: 'backup', scheduled_at: '', webhook_url: '' })
|
||||
loadTasks()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = async (id) => {
|
||||
if (!confirm('Task wirklich abbrechen?')) return
|
||||
try {
|
||||
await api.cancelTask(id)
|
||||
loadTasks()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Task wirklich loeschen?')) return
|
||||
try {
|
||||
await api.deleteTask(id)
|
||||
loadTasks()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const statusBadge = (status) => {
|
||||
const styles = {
|
||||
scheduled: 'bg-gray-700 text-gray-300',
|
||||
running: 'bg-blue-900/50 text-blue-400 border border-blue-700/50',
|
||||
completed: 'bg-green-900/40 text-green-400 border border-green-700/50',
|
||||
failed: 'bg-red-900/40 text-red-400 border border-red-700/50',
|
||||
cancelled: 'bg-yellow-900/40 text-yellow-400 border border-yellow-700/50',
|
||||
}
|
||||
const labels = { scheduled: 'Geplant', running: 'Laeuft', completed: 'Fertig', failed: 'Fehler', cancelled: 'Abgebrochen' }
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded ${styles[status] || 'bg-gray-700 text-gray-400'}`}>
|
||||
{status === 'running' && <span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />}
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const fmtDate = (d) => d ? new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-400">{total} Aufgabe(n)</div>
|
||||
<button
|
||||
onClick={() => setShowDialog(!showDialog)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Aufgabe planen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create Dialog */}
|
||||
{showDialog && (
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Aktion</label>
|
||||
<select
|
||||
value={newTask.action}
|
||||
onChange={e => setNewTask({ ...newTask, action: e.target.value })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500"
|
||||
>
|
||||
<option value="backup">Backup</option>
|
||||
<option value="update">Update</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Zeitpunkt (leer = sofort)</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={newTask.scheduled_at}
|
||||
onChange={e => setNewTask({ ...newTask, scheduled_at: e.target.value })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Webhook-URL (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTask.webhook_url}
|
||||
onChange={e => setNewTask({ ...newTask, webhook_url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-sm text-red-400">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="px-4 py-1.5 bg-green-600 hover:bg-green-700 disabled:bg-gray-700 rounded text-sm text-white transition-colors"
|
||||
>
|
||||
{creating ? 'Erstelle...' : 'Erstellen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowDialog(false); setError('') }}
|
||||
className="px-4 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task Table */}
|
||||
{loading ? (
|
||||
<div className="text-gray-500">Laden...</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="text-gray-500 bg-gray-800/30 rounded-lg border border-gray-800 px-4 py-6 text-center text-sm">
|
||||
Keine Aufgaben vorhanden
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 text-xs border-b border-gray-700">
|
||||
<th className="px-3 py-2">ID</th>
|
||||
<th className="px-3 py-2">Aktion</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
<th className="px-3 py-2">Geplant</th>
|
||||
<th className="px-3 py-2">Gestartet</th>
|
||||
<th className="px-3 py-2">Abgeschlossen</th>
|
||||
<th className="px-3 py-2">Webhook</th>
|
||||
<th className="px-3 py-2 text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{tasks.map(t => (
|
||||
<tr key={t.id}>
|
||||
<td className="px-3 py-2 text-white">{t.id}</td>
|
||||
<td className="px-3 py-2 text-gray-300 capitalize">{t.action}</td>
|
||||
<td className="px-3 py-2">{statusBadge(t.status)}</td>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{fmtDate(t.scheduled_at)}</td>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{fmtDate(t.started_at)}</td>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{fmtDate(t.finished_at)}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{t.webhook_url ? (
|
||||
t.webhook_sent ? (
|
||||
<Check className="w-4 h-4 text-green-400 inline" />
|
||||
) : t.status === 'completed' || t.status === 'failed' ? (
|
||||
<X className="w-4 h-4 text-red-400 inline" />
|
||||
) : (
|
||||
<Clock className="w-3.5 h-3.5 text-gray-500 inline" />
|
||||
)
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{t.status === 'scheduled' && (
|
||||
<button
|
||||
onClick={() => handleCancel(t.id)}
|
||||
className="text-yellow-400 hover:text-yellow-300 p-1 rounded hover:bg-yellow-900/30 transition-colors"
|
||||
title="Abbrechen"
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(t.id)}
|
||||
className="text-red-400 hover:text-red-300 p-1 rounded hover:bg-red-900/30 transition-colors"
|
||||
title="Loeschen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BackupsTab({ agentId }) {
|
||||
const [backups, setBackups] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
Reference in New Issue
Block a user