Quelltext des aktuellen Standes
<?php $this->layout('layouts/admin', [
'page_title' => 'Cronjobs',
'current_admin_page' => 'cronjobs'
]); ?>
<div class="cronjobs-page">
<!-- Crontab-Eintrag -->
<section class="settings-card">
<div class="settings-card-header">
<span class="settings-card-icon">🔌</span>
<div>
<h2><?= $this->te('Crontab-Eintrag') ?></h2>
<p class="settings-card-desc"><?= $this->t('Eine einzige Zeile in <code>crontab -e</code>. Der Scheduler läuft jede Minute, prüft welche Tasks fällig sind, und stößt sie an. Lange Tasks werden im Hintergrund ausgeführt, damit der Tick nicht blockiert.') ?></p>
</div>
</div>
<div class="settings-card-body">
<div class="cj-command-row">
<span class="cj-command-label"><?= $this->te('Scheduler:') ?></span>
<div style="position:relative;">
<code class="cron-command" id="cjMainCmd"><?= $this->e($cron_command) ?></code>
<button class="cj-copy-btn" onclick="cjCopyCmd('cjMainCmd', this)" title="<?= $this->te('Kopieren') ?>">📋</button>
</div>
</div>
<div class="cj-migration-notice">
<?= $this->t('<strong>Migration:</strong> Bisherige Cronjob-Zeilen für <code>send-notifications.php</code>, <code>fetch-tickets.php</code>, <code>process-mail-queue.php</code>, <code>sync-directory.php</code>, <code>relution-sync.php</code> und <code>cve-refresh.php</code> können entfernt werden — der Scheduler ersetzt sie alle. Die alten Skripte bleiben funktionsfähig (z. B. zum Übergang); ein Lock pro Task verhindert Doppel-Läufe falls beide aktiv sind.') ?>
</div>
<p style="margin-top:0.75rem;font-size:0.78rem;color:var(--gray-500);">
<?= $this->t('ⓘ Log-Verzeichnis: <code>{pfad}</code> — muss für den Cron-User schreibbar sein.', ['pfad' => $this->e($log_dir)]) ?>
</p>
</div>
</section>
<!-- Webcron (URL-Aufruf) -->
<section class="settings-card">
<div class="settings-card-header">
<span class="settings-card-icon">🌐</span>
<div>
<h2><?= $this->te('Webcron (URL-Aufruf)') ?></h2>
<p class="settings-card-desc"><?= $this->te('Alternative für Hoster ohne echten Crontab (z. B. All-Inkl KAS): Der Scheduler-Tick wird per URL-Aufruf angestoßen — gleiche Wirkung wie der Crontab-Eintrag oben. Der Endpunkt ist mit einem eigenen Token gesichert und standardmäßig deaktiviert.') ?></p>
</div>
</div>
<div class="settings-card-body">
<form method="post" action="/admin/cronjobs/webcron/settings" style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;margin:0;">
<input type="checkbox" name="webcron_enabled" value="1" <?= !empty($webcron_enabled) ? 'checked' : '' ?>>
<span><?= $this->te('Webcron aktivieren') ?></span>
</label>
<button type="submit" class="btn btn-sm btn-primary"><?= $this->te('Speichern') ?></button>
<?php if (!empty($webcron_enabled)): ?>
<span class="status-pill status-pill--success"><?= $this->te('Aktiv') ?></span>
<?php else: ?>
<span class="status-pill"><?= $this->te('Deaktiviert') ?></span>
<?php endif; ?>
</form>
<?php if (!empty($webcron_url)): ?>
<div class="cj-command-row" style="margin-top:0.9rem;">
<span class="cj-command-label"><?= $this->te('Cronjob-URL:') ?></span>
<div style="position:relative;">
<code class="cron-command" id="cjWebcronUrl"><?= $this->e($webcron_url) ?></code>
<button class="cj-copy-btn" onclick="cjCopyCmd('cjWebcronUrl', this)" title="<?= $this->te('Kopieren') ?>">📋</button>
</div>
</div>
<form method="post" action="/admin/cronjobs/webcron/regenerate-token" style="margin-top:0.6rem;"
onsubmit="return confirm('Neuen Token erzeugen? Die bisherige Cronjob-URL wird sofort ungültig und muss beim Hoster aktualisiert werden.');">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button type="submit" class="btn btn-sm"><?= $this->te('Token neu erzeugen') ?></button>
</form>
<?php endif; ?>
<p style="margin-top:0.75rem;font-size:0.78rem;color:var(--gray-500);">
<?= $this->te('ⓘ Diese URL beim Hoster als Cronjob hinterlegen (bei All-Inkl: KAS → Tools → Cronjobs, kleinstes Intervall meist 5 Minuten). Fällige Tasks werden beim nächsten Aufruf nachgeholt. Den Token geheim halten — wer die URL kennt, kann den Scheduler anstoßen. Bei deaktiviertem Webcron antwortet der Endpunkt mit 403, ohne etwas auszuführen.') ?>
</p>
</div>
</section>
<!-- Tasks -->
<section class="settings-card">
<div class="settings-card-header">
<span class="settings-card-icon">⚙</span>
<div>
<h2><?= $this->te('Geplante Tasks') ?></h2>
<p class="settings-card-desc"><?= $this->te('Intervall, Aktivierung und Hintergrund-Modus pro Task. Änderungen wirken ab dem nächsten Tick.') ?></p>
</div>
</div>
<div class="settings-card-body" style="padding: 0;">
<?php if (empty($tasks)): ?>
<div style="padding:2rem;text-align:center;color:var(--gray-400);">
<p><?= $this->t('Keine Tasks gefunden — wurde Migration <code>009_cron_tasks.sql</code> bereits ausgeführt?') ?></p>
</div>
<?php else:
// Tasks nach Modul/Themenbereich gruppieren (heuristisch über den Task-Namen).
$cronRules = [
['isms', 'Informationssicherheit (ISMS)'],
['contract', 'Verträge'],
['sla', 'Tickets'], ['ticket', 'Tickets'], ['mail_queue', 'Tickets'],
['cve', 'CVE & Sicherheit'],
['mail', 'E-Mail'],
['vault', 'Passwort-Tresor'], ['secret', 'Passwort-Tresor'],
['license', 'Lizenzen'],
['loan', 'Geräteausleihe'],
['handout', 'Inventar'], ['asset', 'Inventar'],
['intune', 'Inventar & MDM'], ['relution', 'Inventar & MDM'],
['warranty', 'Inventar & MDM'], ['dell', 'Inventar & MDM'],
['directory', 'Verzeichnis & Sync'], ['entra', 'Verzeichnis & Sync'],
['backup', 'Backup'],
['configlog', 'Konfigurationslog'],
['netdiagram', 'Netzdiagramm'],
['ai_', 'KI'], ['embedding', 'KI'],
['api_auto', 'API-Automatisierung'],
['file', 'Dateien'],
['review', 'Dokumentation'],
['maintenance', 'Wartung'],
];
$grouped = [];
foreach ($tasks as $t) {
$label = 'Sonstige';
$name = strtolower($t['name']);
foreach ($cronRules as $rule) {
if (str_contains($name, $rule[0])) { $label = $rule[1]; break; }
}
$grouped[$label][] = $t;
}
// Reihenfolge der Regeln; "Sonstige" ans Ende.
$sorted = [];
foreach ($cronRules as $rule) {
if (isset($grouped[$rule[1]]) && !isset($sorted[$rule[1]])) {
$sorted[$rule[1]] = $grouped[$rule[1]];
}
}
foreach ($grouped as $lbl => $rows) {
if (!isset($sorted[$lbl])) { $sorted[$lbl] = $rows; }
}
$grouped = $sorted;
?>
<div class="admin-table-wrap">
<table class="cron-table">
<thead>
<tr>
<th scope="col"><?= $this->te('Task') ?></th>
<th scope="col"><?= $this->te('Intervall (Min.)') ?></th>
<th scope="col"><?= $this->te('Aktiv') ?></th>
<th scope="col" title="<?= $this->te('Lange Tasks im Hintergrund — Scheduler-Tick wartet nicht auf Abschluss.') ?>"><?= $this->te('Hintergrund') ?></th>
<th scope="col"><?= $this->te('Letzter Lauf') ?></th>
<th scope="col"><?= $this->te('Status') ?></th>
<th scope="col" class="cj-col-hide-md"><?= $this->te('Nächster Lauf') ?></th>
<th scope="col" style="width:130px;"></th>
</tr>
</thead>
<tbody>
<?php foreach ($grouped as $groupLabel => $groupTasks): ?>
<tr class="cron-group-row">
<td colspan="8"><?= $this->e($groupLabel) ?> <span class="cron-group-count"><?= count($groupTasks) ?></span></td>
</tr>
<?php foreach ($groupTasks as $task):
$name = $task['name'];
$lastStatus = $task['last_status'] ?? 'never';
$lastRunAt = $task['last_run_at'] ?? null;
$nextRunAt = $task['next_run_at'] ?? null;
$lastMessage = $task['last_message'] ?? '';
?>
<tr id="cjRow-<?= $this->e($name) ?>" data-task="<?= $this->e($name) ?>" class="<?= $task['enabled'] ? '' : 'cj-disabled' ?>">
<td>
<strong><?= $this->e($task['label']) ?></strong>
<small class="cron-desc"><?= $this->e($task['description'] ?? '') ?></small>
<small class="cron-name"><code><?= $this->e($name) ?></code></small>
</td>
<td>
<input type="number" min="1" max="43200"
class="cj-input-freq"
value="<?= (int)$task['frequency_minutes'] ?>"
data-task="<?= $this->e($name) ?>" />
<small class="cron-freq-label"><?= $this->e($task['frequency_label']) ?></small>
</td>
<td>
<label class="cj-switch">
<input type="checkbox" class="cj-toggle-enabled"
data-task="<?= $this->e($name) ?>"
<?= $task['enabled'] ? 'checked' : '' ?> />
<span class="cj-switch-slider"></span>
</label>
</td>
<td>
<label class="cj-switch">
<input type="checkbox" class="cj-toggle-bg"
data-task="<?= $this->e($name) ?>"
<?= $task['run_in_background'] ? 'checked' : '' ?> />
<span class="cj-switch-slider"></span>
</label>
</td>
<td>
<?php if (!$lastRunAt): ?>
<span class="text-muted"><?= $this->te('Noch nie') ?></span>
<?php else: ?>
<small class="cron-date"><?= date('d.m.Y H:i', strtotime($lastRunAt)) ?></small>
<?php endif; ?>
</td>
<td>
<?php if ($lastStatus === 'never'): ?>
<span class="cron-status cron-status-unknown">—</span>
<?php elseif ($lastStatus === 'success'): ?>
<span class="cron-status cron-status-success" title="<?= $this->e($lastMessage) ?>">OK</span>
<?php elseif ($lastStatus === 'running'): ?>
<span class="cron-status cron-status-running"><?= $this->te('Läuft') ?></span>
<?php elseif ($lastStatus === 'skipped'): ?>
<span class="cron-status cron-status-unknown"><?= $this->te('Skipped') ?></span>
<?php else: ?>
<span class="cron-status cron-status-error" title="<?= $this->e($lastMessage) ?>"><?= $this->te('Fehler') ?></span>
<?php endif; ?>
</td>
<td class="cj-col-hide-md">
<?php if ($task['enabled'] && $nextRunAt): ?>
<small class="cron-date"><?= date('d.m.Y H:i', strtotime($nextRunAt)) ?></small>
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
<td style="white-space:nowrap;">
<button class="cj-run-btn cj-run-task" data-task="<?= $this->e($name) ?>" data-label="<?= $this->e($task['label']) ?>">
<?= $this->te('▶ Jetzt') ?>
</button>
</td>
</tr>
<?php endforeach; ?>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</section>
<!-- Letzte Läufe -->
<section class="settings-card">
<div class="settings-card-header">
<span class="settings-card-icon">📋</span>
<div style="flex:1;">
<h2><?= $this->te('Letzte Läufe') ?></h2>
<p class="settings-card-desc"><?= $this->te('Die letzten 50 protokollierten Cronjob-Läufe (alle Tasks).') ?></p>
</div>
<a href="#cjLogSection" class="cj-log-link" onclick="cjShowLog('scheduler');return false;"><?= $this->te('📄 scheduler.log') ?></a>
</div>
<div class="settings-card-body" style="padding: 0;">
<?php if (empty($recent_runs)): ?>
<div style="padding:2rem;text-align:center;color:var(--gray-400);">
<p><?= $this->te('Noch keine Läufe protokolliert.') ?></p>
</div>
<?php else: ?>
<div class="admin-table-wrap">
<table class="cron-table cron-table-compact">
<thead>
<tr>
<th scope="col"><?= $this->te('Zeitpunkt') ?></th>
<th scope="col"><?= $this->te('Task') ?></th>
<th scope="col" class="cj-col-hide-sm"><?= $this->te('Dauer') ?></th>
<th scope="col"><?= $this->te('Status') ?></th>
<th scope="col" class="cj-col-hide-sm"><?= $this->te('Verarbeitet') ?></th>
<th scope="col" class="cj-col-hide-md"><?= $this->te('Meldung') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($recent_runs as $run):
$duration = null;
if (!empty($run['finished_at'])) {
$duration = strtotime($run['finished_at']) - strtotime($run['started_at']);
}
?>
<tr>
<td><?= date('d.m.Y H:i:s', strtotime($run['started_at'])) ?></td>
<td><code style="font-size:0.78rem;"><?= $this->e($run['job_name']) ?></code></td>
<td class="cj-col-hide-sm">
<?= $duration !== null ? ($duration < 1 ? '< 1s' : $duration . 's') : '<span class="text-muted">läuft</span>' ?>
</td>
<td>
<?php if ($run['status'] === 'success'): ?>
<span class="cron-status cron-status-success">OK</span>
<?php elseif ($run['status'] === 'running'): ?>
<span class="cron-status cron-status-running"><?= $this->te('Läuft') ?></span>
<?php else: ?>
<span class="cron-status cron-status-error"><?= $this->te('Fehler') ?></span>
<?php endif; ?>
</td>
<td class="cj-col-hide-sm"><?= (int)($run['items_processed'] ?? 0) ?></td>
<td class="cj-col-hide-md cron-message"><?= $run['message'] ? $this->e(substr($run['message'], 0, 120)) : '-' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</section>
<!-- Log-Viewer -->
<section class="settings-card" id="cjLogSection" style="display:none;">
<div class="settings-card-header">
<span class="settings-card-icon">📄</span>
<div style="flex:1;">
<h2><?= $this->te('Logdatei:') ?> <span id="cjLogTitle"></span></h2>
<p class="settings-card-desc" id="cjLogMeta"></p>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;">
<button class="cj-run-btn" onclick="cjRefreshLog()" id="cjLogRefreshBtn"><?= $this->te('↻ Aktualisieren') ?></button>
<button aria-label="<?= $this->te('Schließen') ?>" class="cj-run-btn" style="color:var(--gray-500);background:var(--gray-100);border-color:var(--gray-200);" onclick="document.getElementById('cjLogSection').style.display='none'">✕</button>
</div>
</div>
<div class="settings-card-body" style="padding:0;">
<pre id="cjLogContent" class="cj-log-pre"><?= $this->te('Lade…') ?></pre>
</div>
</section>
</div>
<div id="cjToast" class="cj-toast" style="display:none;"></div>
<style>
.cron-command {
display: block; padding: 0.65rem 2.5rem 0.65rem 1rem;
background: var(--slate-800); color: var(--slate-200); border-radius: 0.5rem;
font-size: 0.8rem; font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
overflow-x: auto; white-space: nowrap; user-select: all; margin-top: 0.4rem;
}
.cj-command-row { display: flex; flex-direction: column; gap: 0.25rem; position: relative; }
.cj-command-label { font-size: 0.78rem; font-weight: 600; color: var(--gray-500); }
.cj-copy-btn {
position: absolute; right: 0.4rem; bottom: 0.4rem;
background: rgba(255,255,255,0.12); border: none; border-radius: 0.25rem;
color: var(--slate-400); font-size: 0.85rem; padding: 0.2rem 0.45rem; cursor: pointer;
}
.cj-copy-btn:hover { background: rgba(255,255,255,0.22); color: var(--slate-200); }
.cj-migration-notice {
margin-top: 0.85rem; padding: 0.7rem 0.95rem;
background: var(--amber-50); border: 1px solid var(--amber-200); border-radius: 0.4rem;
font-size: 0.82rem; color: var(--amber-900); line-height: 1.5;
}
.cron-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.cron-table th {
text-align: left; padding: 0.65rem 1rem;
background: var(--gray-50); border-bottom: 2px solid var(--gray-200);
color: var(--gray-500); font-weight: 600; font-size: 0.72rem;
text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap;
}
.cron-table td { padding: 0.65rem 1rem; border-bottom: 1px solid var(--gray-100); vertical-align: middle; }
.cron-table-compact td { padding: 0.45rem 1rem; font-size: 0.8rem; }
.cron-table tbody tr:last-child td { border-bottom: none; }
.cron-table tbody tr:hover td { background: var(--gray-50); }
.cron-table tbody tr.cron-group-row td {
background: var(--slate-100); color: var(--slate-700); font-weight: 700;
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.5rem 1rem; border-bottom: 1px solid var(--slate-200);
}
.cron-table tbody tr.cron-group-row:hover td { background: var(--slate-100); }
.cron-group-count {
display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.45rem;
background: var(--slate-300); color: var(--slate-800); border-radius: 9999px;
font-size: 0.7rem; font-weight: 700;
}
.cron-table tbody tr.cj-disabled td { opacity: 0.55; }
.cron-table tbody tr.cj-disabled strong { text-decoration: line-through; }
.cron-desc { display:block; font-size:0.74rem; color:var(--gray-400); margin-top:0.1rem; }
.cron-name { display:block; font-size:0.7rem; color:var(--slate-300); margin-top:0.15rem; }
.cron-name code { background: transparent; color: var(--slate-400); padding: 0; }
.cron-date { display:block; font-size:0.78rem; color:var(--gray-700); }
.cron-freq-label { display:block; font-size:0.68rem; color:var(--gray-400); margin-top:0.15rem; }
.cron-status { font-size:0.68rem; font-weight:700; text-transform:uppercase; padding:0.15rem 0.5rem; border-radius:9999px; white-space:nowrap; }
.cron-status-success { background:var(--emerald-100); color:var(--emerald-800); }
.cron-status-running { background:var(--blue-100); color:var(--blue-800); }
.cron-status-error { background:var(--red-100); color:var(--red-800); }
.cron-status-unknown { background:var(--gray-100); color:var(--gray-500); }
.cron-message { max-width:240px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--gray-500); }
.text-muted { color:var(--gray-400); font-size:0.8rem; }
.cj-input-freq {
width: 80px; padding: 0.3rem 0.45rem; font-size: 0.85rem;
border: 1px solid var(--gray-300); border-radius: 0.3rem;
text-align: center;
}
.cj-input-freq:focus { outline: 2px solid var(--blue-300); outline-offset: -1px; border-color: var(--blue-400); }
/* iOS-style switch */
.cj-switch { position: relative; display: inline-block; width: 36px; height: 20px; }
.cj-switch input { opacity: 0; width: 0; height: 0; }
.cj-switch-slider {
position: absolute; cursor: pointer; inset: 0;
background: var(--slate-300); transition: 0.18s; border-radius: 9999px;
}
.cj-switch-slider:before {
position: absolute; content: ""; height: 14px; width: 14px;
left: 3px; top: 3px; background: var(--color-on-accent); transition: 0.18s; border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.cj-switch input:checked + .cj-switch-slider { background: var(--blue-600); }
.cj-switch input:checked + .cj-switch-slider:before { transform: translateX(16px); }
.cj-run-btn {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.3rem 0.65rem; font-size: 0.78rem; font-weight: 600;
color: var(--blue-600); background: var(--blue-50); border: 1px solid var(--blue-200);
border-radius: 0.375rem; cursor: pointer; transition: all 0.15s; white-space: nowrap;
}
.cj-run-btn:hover { background: var(--blue-100); border-color: var(--blue-300); }
.cj-run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.cj-run-btn.running { color: var(--gray-500); background: var(--gray-100); border-color: var(--gray-200); }
.cj-run-btn.success { color: var(--emerald-800); background: var(--emerald-100); border-color: var(--emerald-300); }
.cj-run-btn.error { color: var(--red-800); background: var(--red-100); border-color: var(--red-300); }
.cj-toast {
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999;
padding: 0.75rem 1.25rem; border-radius: 0.5rem; font-size: 0.875rem; font-weight: 500;
box-shadow: 0 4px 16px rgba(0,0,0,0.15); max-width: 380px;
animation: cjFadeIn 0.2s ease;
}
.cj-toast.success { background: var(--emerald-100); color: var(--emerald-800); border: 1px solid var(--emerald-300); }
.cj-toast.error { background: var(--red-100); color: var(--red-800); border: 1px solid var(--red-300); }
@keyframes cjFadeIn { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
.cj-log-link {
display: inline-flex; align-items: center; gap: 0.2rem;
font-size: 0.78rem; color: var(--gray-500); text-decoration: none;
padding: 0.3rem 0.55rem; border-radius: 0.3rem; border: 1px solid var(--gray-200);
transition: all 0.15s;
}
.cj-log-link:hover { background: var(--gray-100); color: var(--gray-700); border-color: var(--gray-300); text-decoration: none; }
.cj-log-pre {
background: var(--slate-900); color: var(--slate-300);
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
font-size: 0.76rem; line-height: 1.55;
padding: 1rem 1.25rem; margin: 0;
max-height: 480px; overflow-y: auto;
white-space: pre-wrap; word-break: break-all;
border-radius: 0 0 0.25rem 0.25rem;
}
@media (max-width: 640px) { .cj-col-hide-sm { display: none; } .cron-command { font-size: 0.72rem; } }
@media (max-width: 900px) { .cj-col-hide-md { display: none; } }
</style>
<script>
(function () {
'use strict';
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
function getCsrf() { return csrfMeta ? csrfMeta.content : ''; }
function showToast(msg, type) {
var t = document.getElementById('cjToast');
t.textContent = msg;
t.className = 'cj-toast ' + (type || 'success');
t.style.display = 'block';
clearTimeout(t._timeout);
t._timeout = setTimeout(function() { t.style.display = 'none'; }, 5000);
}
function postUpdate(taskName, payload) {
var body = new URLSearchParams();
body.append('csrf_token', getCsrf());
Object.keys(payload).forEach(function(k) { body.append(k, payload[k]); });
return fetch('/admin/cronjobs/' + encodeURIComponent(taskName) + '/update', {
method: 'POST',
headers: {
'X-CSRF-Token': getCsrf(),
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
}).then(function(r) { return r.json(); });
}
// Frequency input — debounced save on change/blur
document.querySelectorAll('.cj-input-freq').forEach(function(input) {
var saveTimer;
function save() {
var taskName = input.dataset.task;
var val = parseInt(input.value, 10);
if (isNaN(val) || val < 1) { val = 1; input.value = 1; }
if (val > 43200) { val = 43200; input.value = 43200; }
input.disabled = true;
postUpdate(taskName, { frequency_minutes: val })
.then(function(d) {
input.disabled = false;
if (d.ok) {
showToast('✓ Intervall aktualisiert (' + (d.task ? d.task.frequency_label : val + ' Min.') + ')', 'success');
if (d.task && d.task.frequency_label) {
var lbl = input.parentElement.querySelector('.cron-freq-label');
if (lbl) lbl.textContent = d.task.frequency_label;
}
} else {
showToast('✕ ' + (d.error || 'Fehler beim Speichern'), 'error');
}
}).catch(function(e) {
input.disabled = false;
showToast('Netzwerkfehler: ' + e.message, 'error');
});
}
input.addEventListener('change', save);
input.addEventListener('blur', function() {
clearTimeout(saveTimer);
saveTimer = setTimeout(save, 100);
});
});
// Enabled toggle
document.querySelectorAll('.cj-toggle-enabled').forEach(function(cb) {
cb.addEventListener('change', function() {
var taskName = cb.dataset.task;
var row = document.getElementById('cjRow-' + taskName);
cb.disabled = true;
postUpdate(taskName, { enabled: cb.checked ? '1' : '0' })
.then(function(d) {
cb.disabled = false;
if (d.ok) {
if (row) row.classList.toggle('cj-disabled', !cb.checked);
showToast('✓ ' + (cb.checked ? 'Aktiviert' : 'Deaktiviert'), 'success');
} else {
cb.checked = !cb.checked;
showToast('✕ ' + (d.error || 'Fehler'), 'error');
}
}).catch(function(e) {
cb.disabled = false; cb.checked = !cb.checked;
showToast('Netzwerkfehler: ' + e.message, 'error');
});
});
});
// Background toggle
document.querySelectorAll('.cj-toggle-bg').forEach(function(cb) {
cb.addEventListener('change', function() {
var taskName = cb.dataset.task;
cb.disabled = true;
postUpdate(taskName, { run_in_background: cb.checked ? '1' : '0' })
.then(function(d) {
cb.disabled = false;
if (d.ok) {
showToast('✓ Hintergrund-Modus: ' + (cb.checked ? 'an' : 'aus'), 'success');
} else {
cb.checked = !cb.checked;
showToast('✕ ' + (d.error || 'Fehler'), 'error');
}
}).catch(function(e) {
cb.disabled = false; cb.checked = !cb.checked;
showToast('Netzwerkfehler: ' + e.message, 'error');
});
});
});
// "Run now" button
document.querySelectorAll('.cj-run-task').forEach(function(btn) {
btn.addEventListener('click', function() {
var taskName = btn.dataset.task;
var label = btn.dataset.label;
btn.disabled = true;
btn.classList.add('running');
btn.textContent = '⏳ Läuft…';
var body = new URLSearchParams();
body.append('csrf_token', getCsrf());
fetch('/admin/cronjobs/' + encodeURIComponent(taskName) + '/run', {
method: 'POST',
headers: {
'X-CSRF-Token': getCsrf(),
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
})
.then(function(r) { return r.json(); })
.then(function(d) {
btn.classList.remove('running');
if (d.ok) {
btn.classList.add('success');
btn.textContent = '✓ Erledigt';
showToast('✓ ' + label + ': ' + (d.message || 'Erfolgreich'), 'success');
setTimeout(function() { location.reload(); }, 2500);
} else {
btn.classList.add('error');
btn.textContent = '✕ Fehler';
showToast('✕ ' + label + ': ' + (d.error || 'Unbekannter Fehler'), 'error');
setTimeout(function() {
btn.classList.remove('error');
btn.textContent = '▶ Jetzt';
btn.disabled = false;
}, 4000);
}
})
.catch(function(e) {
btn.classList.remove('running');
btn.classList.add('error');
btn.textContent = '✕ Fehler';
showToast('Netzwerkfehler: ' + e.message, 'error');
setTimeout(function() {
btn.classList.remove('error');
btn.textContent = '▶ Jetzt';
btn.disabled = false;
}, 4000);
});
});
});
// --- Log viewer (kept from old template, simplified) ---------------------
window.cjCurrentLog = null;
window.cjShowLog = function(name) {
window.cjCurrentLog = name;
var section = document.getElementById('cjLogSection');
var title = document.getElementById('cjLogTitle');
var content = document.getElementById('cjLogContent');
var meta = document.getElementById('cjLogMeta');
title.textContent = name + '.log';
content.textContent = 'Lade…';
meta.textContent = '';
section.style.display = '';
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
cjLoadLog(name);
};
window.cjRefreshLog = function() { if (window.cjCurrentLog) cjLoadLog(window.cjCurrentLog); };
function cjLoadLog(name) {
var content = document.getElementById('cjLogContent');
var meta = document.getElementById('cjLogMeta');
var btn = document.getElementById('cjLogRefreshBtn');
if (btn) { btn.disabled = true; btn.textContent = '⏳ Lade…'; }
fetch('/admin/cronjobs/log/' + encodeURIComponent(name), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (btn) { btn.disabled = false; btn.textContent = '↻ Aktualisieren'; }
if (d.ok) {
content.textContent = d.content;
content.scrollTop = content.scrollHeight;
if (d.mtime) meta.textContent = 'Zuletzt geändert: ' + d.mtime + (d.size ? ' · ' + Math.ceil(d.size / 1024) + ' KB' : '');
} else {
content.textContent = 'Fehler: ' + (d.error || 'Unbekannt');
}
})
.catch(function(e) {
if (btn) { btn.disabled = false; btn.textContent = '↻ Aktualisieren'; }
content.textContent = 'Netzwerkfehler: ' + e.message;
});
}
// Copy button for the crontab line
window.cjCopyCmd = function(elementId, btn) {
var el = document.getElementById(elementId);
if (!el) return;
navigator.clipboard.writeText(el.textContent).then(function() {
if (btn) {
var orig = btn.textContent;
btn.textContent = '✓';
setTimeout(function() { btn.textContent = orig; }, 1500);
}
});
};
})();
</script>