import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from 'primereact/button'; import { Dialog } from 'primereact/dialog'; import { InputText } from 'primereact/inputtext'; import { Dropdown } from 'primereact/dropdown'; import { InputSwitch } from 'primereact/inputswitch'; import { Toast } from 'primereact/toast'; import { api } from '../api/client'; // ── Hilfsfunktionen ────────────────────────────────────────────────────────── function formatDateTime(iso) { if (!iso) return '–'; try { return new Date(iso).toLocaleString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); } catch (_) { return iso; } } function StatusBadge({ status }) { if (!status) return ; const map = { success: { label: 'Erfolg', cls: 'success' }, error: { label: 'Fehler', cls: 'error' }, running: { label: 'Läuft…', cls: 'running' } }; const info = map[status] || { label: status, cls: 'none' }; return {info.label}; } function normalizeActiveCronRuns(rawPayload) { const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {}; const active = Array.isArray(payload.active) ? payload.active : []; return active .map((item) => (item && typeof item === 'object' ? item : null)) .filter(Boolean) .filter((item) => String(item.type || '').trim().toLowerCase() === 'cron') .map((item) => ({ id: Number(item.id), cronJobId: Number(item.cronJobId || 0), currentStep: String(item.currentStep || '').trim() || null, currentScriptName: String(item.currentScriptName || '').trim() || null, startedAt: item.startedAt || null })) .filter((item) => Number.isFinite(item.cronJobId) && item.cronJobId > 0); } const EMPTY_FORM = { name: '', cronExpression: '', sourceType: 'script', sourceId: null, enabled: true, pushoverEnabled: true }; // ── Hauptkomponente ─────────────────────────────────────────────────────────── export default function CronJobsTab({ onWsMessage }) { const toastRef = useRef(null); const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(false); const [scripts, setScripts] = useState([]); const [chains, setChains] = useState([]); const [activeCronRuns, setActiveCronRuns] = useState([]); // Editor-Dialog const [editorOpen, setEditorOpen] = useState(false); const [editorMode, setEditorMode] = useState('create'); // 'create' | 'edit' const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const [saving, setSaving] = useState(false); // Cron-Validierung const [exprValidation, setExprValidation] = useState(null); // { valid, error, nextRunAt } const [exprValidating, setExprValidating] = useState(false); const exprValidateTimer = useRef(null); // Logs-Dialog const [logsJob, setLogsJob] = useState(null); const [logs, setLogs] = useState([]); const [logsLoading, setLogsLoading] = useState(false); // Aktionen Busy-State per Job-ID const [busyId, setBusyId] = useState(null); // ── Daten laden ────────────────────────────────────────────────────────────── const loadAll = useCallback(async () => { setLoading(true); try { const [cronResp, scriptsResp, chainsResp, runtimeResp] = await Promise.allSettled([ api.getCronJobs(), api.getScripts(), api.getScriptChains(), api.getRuntimeActivities() ]); if (cronResp.status === 'fulfilled') setJobs(cronResp.value?.jobs || []); if (scriptsResp.status === 'fulfilled') setScripts(scriptsResp.value?.scripts || []); if (chainsResp.status === 'fulfilled') setChains(chainsResp.value?.chains || []); if (runtimeResp.status === 'fulfilled') { setActiveCronRuns(normalizeActiveCronRuns(runtimeResp.value)); } } finally { setLoading(false); } }, []); useEffect(() => { loadAll(); }, [loadAll]); useEffect(() => { let cancelled = false; const refreshRuntime = async () => { try { const response = await api.getRuntimeActivities(); if (!cancelled) { setActiveCronRuns(normalizeActiveCronRuns(response)); } } catch (_error) { // ignore polling errors } }; const interval = setInterval(refreshRuntime, 2500); return () => { cancelled = true; clearInterval(interval); }; }, []); const activeCronRunByJobId = useMemo(() => { const map = new Map(); for (const item of activeCronRuns) { if (!item?.cronJobId) { continue; } map.set(item.cronJobId, item); } return map; }, [activeCronRuns]); // WebSocket: Cronjob-Updates empfangen useEffect(() => { if (!onWsMessage) return; // onWsMessage ist eine Funktion, die wir anmelden }, [onWsMessage]); // ── Cron-Ausdruck validieren (debounced) ───────────────────────────────────── useEffect(() => { const expr = form.cronExpression.trim(); if (!expr) { setExprValidation(null); return; } if (exprValidateTimer.current) clearTimeout(exprValidateTimer.current); setExprValidating(true); exprValidateTimer.current = setTimeout(async () => { try { const result = await api.validateCronExpression(expr); setExprValidation(result); } catch (_) { setExprValidation({ valid: false, error: 'Validierung fehlgeschlagen.' }); } finally { setExprValidating(false); } }, 500); return () => clearTimeout(exprValidateTimer.current); }, [form.cronExpression]); // ── Editor öffnen/schließen ────────────────────────────────────────────────── function openCreate() { setForm(EMPTY_FORM); setExprValidation(null); setEditorMode('create'); setEditingId(null); setEditorOpen(true); } function openEdit(job) { setForm({ name: job.name || '', cronExpression: job.cronExpression || '', sourceType: job.sourceType || 'script', sourceId: job.sourceId || null, enabled: job.enabled !== false, pushoverEnabled: job.pushoverEnabled !== false }); setExprValidation(null); setEditorMode('edit'); setEditingId(job.id); setEditorOpen(true); } function closeEditor() { setEditorOpen(false); setSaving(false); } // ── Speichern ──────────────────────────────────────────────────────────────── async function handleSave() { const name = form.name.trim(); const cronExpression = form.cronExpression.trim(); if (!name) { toastRef.current?.show({ severity: 'warn', summary: 'Name fehlt', life: 3000 }); return; } if (!cronExpression) { toastRef.current?.show({ severity: 'warn', summary: 'Cron-Ausdruck fehlt', life: 3000 }); return; } if (exprValidation && !exprValidation.valid) { toastRef.current?.show({ severity: 'warn', summary: 'Ungültiger Cron-Ausdruck', life: 3000 }); return; } if (!form.sourceId) { toastRef.current?.show({ severity: 'warn', summary: 'Quelle fehlt', life: 3000 }); return; } setSaving(true); try { const payload = { ...form, name, cronExpression }; if (editorMode === 'create') { await api.createCronJob(payload); toastRef.current?.show({ severity: 'success', summary: 'Cronjob erstellt', life: 3000 }); } else { await api.updateCronJob(editingId, payload); toastRef.current?.show({ severity: 'success', summary: 'Cronjob gespeichert', life: 3000 }); } closeEditor(); await loadAll(); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 5000 }); } finally { setSaving(false); } } // ── Toggle enabled/pushover ────────────────────────────────────────────────── async function handleToggle(job, field) { setBusyId(job.id); try { const updated = await api.updateCronJob(job.id, { [field]: !job[field] }); setJobs((prev) => prev.map((j) => j.id === job.id ? updated.job : j)); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 4000 }); } finally { setBusyId(null); } } // ── Löschen ────────────────────────────────────────────────────────────────── async function handleDelete(job) { if (!window.confirm(`Cronjob "${job.name}" wirklich löschen?`)) return; setBusyId(job.id); try { await api.deleteCronJob(job.id); toastRef.current?.show({ severity: 'success', summary: 'Gelöscht', life: 3000 }); setJobs((prev) => prev.filter((j) => j.id !== job.id)); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 4000 }); } finally { setBusyId(null); } } // ── Manuell ausführen ──────────────────────────────────────────────────────── async function handleRunNow(job) { setBusyId(job.id); try { await api.runCronJobNow(job.id); toastRef.current?.show({ severity: 'info', summary: `"${job.name}" gestartet`, life: 3000 }); // Kurz warten und dann neu laden setTimeout(() => loadAll(), 1500); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message, life: 4000 }); } finally { setBusyId(null); } } // ── Logs ───────────────────────────────────────────────────────────────────── async function openLogs(job) { setLogsJob(job); setLogs([]); setLogsLoading(true); try { const resp = await api.getCronJobLogs(job.id, 30); setLogs(resp.logs || []); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Logs konnten nicht geladen werden', detail: error.message, life: 4000 }); } finally { setLogsLoading(false); } } // ── Source-Optionen ────────────────────────────────────────────────────────── const sourceOptions = form.sourceType === 'script' ? scripts.map((s) => ({ label: s.name, value: s.id })) : chains.map((c) => ({ label: c.name, value: c.id })); // ── Render ──────────────────────────────────────────────────────────────────── return (
{jobs.length === 0 && !loading && (

Keine Cronjobs vorhanden. Klicke auf “Neuer Cronjob”, um einen anzulegen.

)} {jobs.length > 0 && (
{jobs.map((job) => { const isBusy = busyId === job.id; const activeCronRun = activeCronRunByJobId.get(Number(job.id)) || null; return (
{job.name} {job.cronExpression}
Quelle: {job.sourceType === 'chain' ? '⛓ ' : '📜 '} {job.sourceName || `#${job.sourceId}`} Letzter Lauf: {formatDateTime(job.lastRunAt)} {job.lastRunStatus && } Nächster Lauf: {formatDateTime(job.nextRunAt)} {activeCronRun ? ( Aktuell: {activeCronRun.currentScriptName ? `Skript: ${activeCronRun.currentScriptName}` : (activeCronRun.currentStep || 'Ausführung läuft')} ) : null}
); })}
)} {/* ── Editor-Dialog ──────────────────────────────────────────────────── */}
} >
{/* Name */}
setForm((f) => ({ ...f, name: e.target.value }))} placeholder="z.B. Tägliche Bereinigung" className="w-full" />
{/* Cron-Ausdruck */}
setForm((f) => ({ ...f, cronExpression: e.target.value }))} placeholder="Minute Stunde Tag Monat Wochentag – z.B. 0 2 * * *" className={`w-full${exprValidation && !exprValidation.valid ? ' p-invalid' : ''}`} /> {exprValidating && ( Wird geprüft… )} {!exprValidating && exprValidation && exprValidation.valid && ( ✓ Gültig – nächste Ausführung: {formatDateTime(exprValidation.nextRunAt)} )} {!exprValidating && exprValidation && !exprValidation.valid && ( ✗ {exprValidation.error} )}
{[ { label: 'Stündlich', expr: '0 * * * *' }, { label: 'Täglich 2 Uhr', expr: '0 2 * * *' }, { label: 'Wöchentlich Mo', expr: '0 3 * * 1' }, { label: 'Monatlich 1.', expr: '0 4 1 * *' } ].map(({ label, expr }) => ( ))}
{/* Quell-Typ */}
{[ { value: 'script', label: '📜 Skript' }, { value: 'chain', label: '⛓ Skriptkette' } ].map(({ value, label }) => ( ))}
{/* Quelle auswählen */}
setForm((f) => ({ ...f, sourceId: e.value }))} placeholder={`${form.sourceType === 'script' ? 'Skript' : 'Skriptkette'} wählen…`} className="w-full" emptyMessage={form.sourceType === 'script' ? 'Keine Skripte vorhanden' : 'Keine Ketten vorhanden'} />
{/* Toggles */}
{/* ── Logs-Dialog ──────────────────────────────────────────────────────── */} setLogsJob(null)} style={{ width: '720px' }} footer={ ); }