Feature: Recurring jobs with professional scheduling dialog

This commit is contained in:
cynfo3000
2026-03-05 09:39:41 +01:00
parent f4a28463b7
commit 721fd5008f
8 changed files with 505 additions and 330 deletions
+4
View File
@@ -315,6 +315,10 @@ class ApiClient {
return this.del(`/api/v1/tasks/${id}`)
}
toggleTask(id) {
return this.put(`/api/v1/tasks/${id}/toggle`)
}
// Installer
getInstallerInfo() {
return this.get('/api/v1/installer')
+160 -158
View File
@@ -6,7 +6,7 @@ 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, Ban,
FileKey, UserCheck, Database, CalendarClock, Cable, Settings, Server, GitBranch,
FileKey, UserCheck, Database, CalendarClock, Cable, Settings, Server, GitBranch, Play, Power, ChevronDown, ChevronRight,
} from 'lucide-react'
// Two-level navigation structure (DATAZONE style)
@@ -118,7 +118,7 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) {
if (tab === 'certs') return <CertsTab sys={sys} />
if (tab === 'updates') return <UpdatesTab agentId={agentId} />
if (tab === 'security') return <SecurityTab sys={sys} />
if (tab === 'tasks') return <TasksTab agentId={agentId} />
if (tab === 'tasks') return <TasksTab agentId={agentId} agentName={agent?.name || ''} />
if (tab === 'backups') return platform === 'linux' ? <PVEBackupsTab sys={sys} /> : <BackupsTab agentId={agentId} />
if (tab === 'agent') return <AgentTab agent={data?.agent} status={data?.status} platform={platform} />
if (tab === 'haproxy') return <HAProxyTab sys={sys} />
@@ -1616,222 +1616,224 @@ function SecurityTab({ sys }) {
)
}
function TasksTab({ agentId }) {
function TasksTab({ agentId, agentName }) {
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 [showWebhook, setShowWebhook] = useState(false)
const defaultForm = {
name: '', action: 'backup', schedule_type: 'once', schedule_time: '02:00',
scheduled_at: '', schedule_weekday: 1, schedule_monthday: 1,
reboot: false, health_check: false, health_check_timeout: 600,
webhook_url: '', active: true,
}
const [form, setForm] = useState(defaultForm)
const loadTasks = () => {
api.getTasks({ agent_id: agentId, limit: 50 })
.then(resp => {
setTasks(resp.data || [])
setTotal(resp.total || 0)
})
.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])
useEffect(() => { const iv = setInterval(loadTasks, 15000); return () => clearInterval(iv) }, [agentId])
const handleCreate = async () => {
setCreating(true)
setError('')
setCreating(true); setError('')
try {
const data = {
agent_id: agentId,
action: newTask.action,
const data = { agent_id: agentId, name: form.name, action: form.action, schedule_type: form.schedule_type, schedule_time: form.schedule_time, active: form.active }
if (form.schedule_type === 'once' && form.scheduled_at) {
data.scheduled_at = new Date(form.scheduled_at).toISOString()
}
if (newTask.scheduled_at) {
// Convert datetime-local to RFC3339
const d = new Date(newTask.scheduled_at)
data.scheduled_at = d.toISOString()
if (form.schedule_type === 'weekly') data.schedule_weekday = parseInt(form.schedule_weekday)
if (form.schedule_type === 'monthly') data.schedule_monthday = parseInt(form.schedule_monthday)
if (form.action === 'update') {
data.reboot = form.reboot; data.health_check = form.health_check
if (form.health_check) data.health_check_timeout = parseInt(form.health_check_timeout) || 600
}
if (newTask.webhook_url) data.webhook_url = newTask.webhook_url
if (form.webhook_url) data.webhook_url = form.webhook_url
await api.createTask(data)
setShowDialog(false)
setNewTask({ action: 'backup', scheduled_at: '', webhook_url: '' })
loadTasks()
} catch (e) {
setError(e.message)
} finally {
setCreating(false)
}
setShowDialog(false); setForm(defaultForm); setShowWebhook(false); 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 handleToggle = async (id) => {
try { await api.toggleTask(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)
}
if (!confirm('Job wirklich loeschen?')) return
try { await api.deleteTask(id); loadTasks() } catch (e) { setError(e.message) }
}
const handleRunNow = async (id) => {
// Trigger by creating a one-off copy? For now just toggle to trigger scheduler
// Simple approach: we don't have a "run now" endpoint, so we skip for now
}
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 weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
const fmtInterval = (t) => {
if (t.schedule_type === 'daily') return `Täglich ${t.schedule_time}`
if (t.schedule_type === 'weekly') return `Wöchentlich ${weekdays[t.schedule_weekday || 0]} ${t.schedule_time}`
if (t.schedule_type === 'monthly') return `Monatlich ${t.schedule_monthday || 1}. ${t.schedule_time}`
return 'Einmalig'
}
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' }) : '—'
const inputCls = 'w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500'
const labelCls = 'block text-xs text-gray-400 mb-1'
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
<div className="text-sm text-gray-400">{total} Job(s)</div>
<button onClick={() => setShowDialog(true)} 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" /> Neuer Job
</button>
</div>
{/* Create Dialog */}
{/* Modal */}
{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 className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setShowDialog(false)}>
<div className="bg-gray-800 rounded-lg border border-gray-700 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-700">
<h3 className="text-white font-semibold">Neuer Job{agentName ? ` — ${agentName}` : ''}</h3>
<button onClick={() => setShowDialog(false)} className="text-gray-400 hover:text-white"><X className="w-5 h-5" /></button>
</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 className="px-5 py-4 space-y-4">
<div>
<label className={labelCls}>Name *</label>
<input type="text" value={form.name} onChange={e => setForm({...form, name: e.target.value})} placeholder="z.B. Monatliches Update" className={inputCls} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>Intervall *</label>
<select value={form.schedule_type} onChange={e => setForm({...form, schedule_type: e.target.value})} className={inputCls}>
<option value="once">Einmalig</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
<option value="monthly">Monatlich</option>
</select>
</div>
<div>
<label className={labelCls}>Uhrzeit *</label>
<input type="time" value={form.schedule_time} onChange={e => setForm({...form, schedule_time: e.target.value})} className={inputCls} />
</div>
</div>
{form.schedule_type === 'once' && (
<div>
<label className={labelCls}>Datum</label>
<input type="datetime-local" value={form.scheduled_at} onChange={e => setForm({...form, scheduled_at: e.target.value})} className={inputCls} />
</div>
)}
{form.schedule_type === 'weekly' && (
<div>
<label className={labelCls}>Wochentag</label>
<select value={form.schedule_weekday} onChange={e => setForm({...form, schedule_weekday: e.target.value})} className={inputCls}>
{weekdays.map((d, i) => <option key={i} value={i}>{d}</option>)}
</select>
</div>
)}
{form.schedule_type === 'monthly' && (
<div>
<label className={labelCls}>Tag im Monat</label>
<input type="number" min="1" max="28" value={form.schedule_monthday} onChange={e => setForm({...form, schedule_monthday: e.target.value})} className={inputCls} />
</div>
)}
<div>
<label className={labelCls}>Aktion *</label>
<div className="flex gap-2">
{['backup', 'update'].map(a => (
<button key={a} onClick={() => setForm({...form, action: a})}
className={`flex-1 py-2 rounded text-sm font-medium transition-colors ${form.action === a ? 'bg-orange-600 text-white' : 'bg-gray-700 text-gray-400 hover:bg-gray-600'}`}>
{a === 'backup' ? 'Backup' : 'Update'}
</button>
))}
</div>
</div>
{form.action === 'update' && (
<div className="space-y-2 pl-1">
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={form.reboot} onChange={e => setForm({...form, reboot: e.target.checked})} className="accent-orange-500" />
Reboot nach Update
</label>
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={form.health_check} onChange={e => setForm({...form, health_check: e.target.checked})} className="accent-orange-500" />
Health-Check nach Update
</label>
{form.health_check && (
<div className="pl-6">
<label className={labelCls}>Health-Check Timeout (Sekunden)</label>
<input type="number" value={form.health_check_timeout} onChange={e => setForm({...form, health_check_timeout: e.target.value})} className={inputCls + ' max-w-[200px]'} />
</div>
)}
</div>
)}
<div>
<button onClick={() => setShowWebhook(!showWebhook)} className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-300">
{showWebhook ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />} Webhook-URL (optional)
</button>
{showWebhook && (
<input type="text" value={form.webhook_url} onChange={e => setForm({...form, webhook_url: e.target.value})} placeholder="https://..." className={inputCls + ' mt-1'} />
)}
</div>
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={form.active} onChange={e => setForm({...form, active: e.target.checked})} className="accent-orange-500" />
Job ist aktiv
</label>
{error && <div className="text-sm text-red-400">{error}</div>}
</div>
<div className="flex justify-end gap-2 px-5 py-4 border-t border-gray-700">
<button onClick={() => { setShowDialog(false); setError('') }} className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors">Abbrechen</button>
<button onClick={handleCreate} disabled={creating || !form.name} className="px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 disabled:text-gray-500 rounded text-sm text-white transition-colors">
{creating ? 'Erstelle...' : 'Erstellen'}
</button>
</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 */}
{/* 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="text-gray-500 bg-gray-800/30 rounded-lg border border-gray-800 px-4 py-6 text-center text-sm">Keine Jobs 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 w-8">Status</th>
<th className="px-3 py-2">Name</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">Intervall</th>
<th className="px-3 py-2">Nächster Lauf</th>
<th className="px-3 py-2">Letzter Lauf</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}
<tr key={t.id} className={!t.active ? 'opacity-50' : ''}>
<td className="px-3 py-2">
<span className={`inline-block w-2.5 h-2.5 rounded-full ${t.active ? 'bg-green-500' : 'bg-gray-500'}`} title={t.active ? 'Aktiv' : 'Inaktiv'} />
</td>
<td className="px-3 py-2 text-white">{t.name || `Job #${t.id}`}</td>
<td className="px-3 py-2 text-gray-300 capitalize">{t.action}</td>
<td className="px-3 py-2 text-gray-400 text-xs">{fmtInterval(t)}</td>
<td className="px-3 py-2 text-gray-400 text-xs">{fmtDate(t.next_run)}</td>
<td className="px-3 py-2 text-gray-400 text-xs">{fmtDate(t.last_run)}</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"
>
<button onClick={() => handleToggle(t.id)} className={`p-1 rounded transition-colors ${t.active ? 'text-green-400 hover:text-green-300 hover:bg-green-900/30' : 'text-gray-500 hover:text-gray-300 hover:bg-gray-700'}`} title={t.active ? 'Deaktivieren' : 'Aktivieren'}>
<Power 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="Löschen">
<Trash2 className="w-4 h-4" />
</button>
</div>