diff --git a/public/admin/cron/index.php b/public/admin/cron/index.php
index 189c371e..239b6d81 100644
--- a/public/admin/cron/index.php
+++ b/public/admin/cron/index.php
@@ -102,6 +102,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'timezone' => (string) ($_POST['timezone'] ?? ''),
], $baseUrlFallback);
$messages[] = 'Cron-Job gespeichert: ' . $jobKey;
+ } elseif ($action === 'toggle-job') {
+ $jobKey = (string) ($_POST['job_key'] ?? '');
+ $service->saveJob($jobKey, [
+ 'enabled' => !empty($_POST['enabled']),
+ 'cron_expression' => (string) ($_POST['cron_expression'] ?? ''),
+ 'timezone' => (string) ($_POST['timezone'] ?? ''),
+ ], $baseUrlFallback);
+ $messages[] = 'Status aktualisiert: ' . $jobKey;
} elseif ($action === 'run-now') {
$jobKey = (string) ($_POST['job_key'] ?? '');
$result = $service->runNow($jobKey, $baseUrlFallback);
@@ -238,6 +246,7 @@ ob_start();
-
Status= $h((string) (($job['state']['last_status'] ?? '') ?: ($job['enabled'] ? 'aktiv' : 'inaktiv'))) ?>
-
Naechster Lauf= $h((string) (($job['next_due_at_local'] ?? '') ?: 'n/a')) ?>
-
Letzter Erfolg= $h((string) (($job['state_local']['last_success_at'] ?? '') ?: 'n/a')) ?>
-
Endpoint= $h((string) ($job['endpoint_path'] ?? '')) ?>
+
+
+ Status
+ = $h((string) (($job['state']['last_status'] ?? '') ?: ($job['enabled'] ? 'aktiv' : 'inaktiv'))) ?>
+
+
+ Zeitplan
+ = $h((string) ($job['cron_human'] ?? 'n/a')) ?>
+
+
+ Naechster Lauf
+ = $h((string) (($job['next_due_at_local'] ?? '') ?: 'n/a')) ?>
+
+
+ Letzter Erfolg
+ = $h((string) (($job['state_local']['last_success_at'] ?? '') ?: 'n/a')) ?>
+
+
+
+
+ Cron = $h((string) ($job['cron_expression'] ?? '')) ?>
+ Zeitzone = $h((string) ($job['timezone'] ?? ($settings['default_timezone'] ?? 'Europe/Berlin'))) ?>
+ Endpoint = $h((string) ($job['endpoint_path'] ?? '')) ?>
@@ -269,14 +299,32 @@ ob_start();
= $h((string) $job['state']['last_message']) ?>
-
-
-
diff --git a/public/assets/apps/cron-management/app.css b/public/assets/apps/cron-management/app.css
index c2fda945..93b74fc0 100644
--- a/public/assets/apps/cron-management/app.css
+++ b/public/assets/apps/cron-management/app.css
@@ -41,6 +41,11 @@
gap: 14px;
}
+#cron-management-app .cm-job-card {
+ gap: 12px;
+ padding: 18px 20px;
+}
+
#cron-management-app .cm-side-title,
#cron-management-app .cm-section-title {
margin: 0;
@@ -118,7 +123,7 @@
#cron-management-app .cm-job-list {
display: grid;
- gap: 16px;
+ gap: 14px;
}
#cron-management-app .cm-job-head,
@@ -141,6 +146,70 @@
gap: 12px;
}
+#cron-management-app .cm-job-summary {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 10px;
+}
+
+#cron-management-app .cm-job-summary-item,
+#cron-management-app .cm-job-inline-meta {
+ font-size: 13px;
+ line-height: 1.45;
+ color: #475569;
+}
+
+#cron-management-app .cm-job-summary-item {
+ padding: 10px 12px;
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ border-radius: 12px;
+ background: rgba(248, 250, 252, 0.82);
+}
+
+#cron-management-app .cm-job-summary-item span {
+ display: block;
+}
+
+#cron-management-app .cm-job-summary-item strong {
+ display: block;
+ margin-top: 4px;
+ font-size: 14px;
+ color: #0f172a;
+}
+
+#cron-management-app .cm-job-inline-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px 14px;
+}
+
+#cron-management-app .cm-job-inline-meta span {
+ display: inline-flex;
+ gap: 6px;
+ align-items: baseline;
+}
+
+#cron-management-app .cm-job-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+#cron-management-app .cm-form--job {
+ padding: 14px;
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ border-radius: 14px;
+ background: rgba(248, 250, 252, 0.85);
+}
+
+#cron-management-app .cm-form--job[hidden] {
+ display: none;
+}
+
+#cron-management-app .cm-form--job .cm-field input {
+ min-height: 42px;
+}
+
#cron-management-app .cm-status-grid strong {
display: block;
margin-top: 4px;
diff --git a/public/assets/apps/cron-management/app.js b/public/assets/apps/cron-management/app.js
index eb24f43e..6eaee07d 100644
--- a/public/assets/apps/cron-management/app.js
+++ b/public/assets/apps/cron-management/app.js
@@ -22,6 +22,38 @@
};
const bindInteractions = (host, loadRoute) => {
+ host.querySelectorAll('[data-cm-edit-toggle]').forEach((button) => {
+ if (button.dataset.cmBound === '1') {
+ return;
+ }
+
+ button.dataset.cmBound = '1';
+ button.addEventListener('click', () => {
+ const targetId = String(button.getAttribute('data-cm-edit-toggle') || '');
+ const target = targetId !== '' ? host.querySelector(`[data-cm-edit-target="${CSS.escape(targetId)}"]`) : null;
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+
+ target.hidden = !target.hidden;
+ });
+ });
+
+ host.querySelectorAll('[data-cm-edit-close]').forEach((button) => {
+ if (button.dataset.cmBound === '1') {
+ return;
+ }
+
+ button.dataset.cmBound = '1';
+ button.addEventListener('click', () => {
+ const targetId = String(button.getAttribute('data-cm-edit-close') || '');
+ const target = targetId !== '' ? host.querySelector(`[data-cm-edit-target="${CSS.escape(targetId)}"]`) : null;
+ if (target instanceof HTMLElement) {
+ target.hidden = true;
+ }
+ });
+ });
+
host.querySelectorAll('form[action]').forEach((form) => {
if (form.dataset.cmBound === '1') {
return;
diff --git a/src/App/CronManagementService.php b/src/App/CronManagementService.php
index e4a50229..b567911f 100644
--- a/src/App/CronManagementService.php
+++ b/src/App/CronManagementService.php
@@ -155,6 +155,7 @@ final class CronManagementService
foreach ($definitions as $jobKey => $definition) {
$config = $this->jobConfig($jobKey, $definition, $settings);
$state = is_array($states[$jobKey] ?? null) ? $states[$jobKey] : [];
+ $configured = is_array($settings['jobs'][$jobKey] ?? null);
$timezone = $this->safeTimezone((string) ($config['timezone'] ?? $settings['default_timezone']));
$expression = trim((string) ($config['cron_expression'] ?? ''));
$parseError = null;
@@ -181,6 +182,7 @@ final class CronManagementService
'job_key' => $jobKey,
'config' => $config,
'state' => $state,
+ 'configured' => $configured,
'state_local' => [
'last_started_at' => $this->formatLocal((string) ($state['last_started_at'] ?? ''), $timezone),
'last_finished_at' => $this->formatLocal((string) ($state['last_finished_at'] ?? ''), $timezone),
@@ -189,6 +191,7 @@ final class CronManagementService
],
'enabled' => !empty($config['enabled']),
'cron_expression' => $expression,
+ 'cron_human' => $this->humanizeCronExpression($expression),
'timezone' => $timezone->getName(),
'parse_error' => $parseError,
'is_due' => $isDue,
@@ -373,6 +376,73 @@ final class CronManagementService
return $parsed?->setTimezone($timezone)->format('d.m.Y H:i');
}
+ private function humanizeCronExpression(string $expression): string
+ {
+ $parts = preg_split('/\s+/', trim($expression)) ?: [];
+ if (count($parts) !== 5) {
+ return 'Benutzerdefinierter Cron-Ausdruck';
+ }
+
+ [$minute, $hour, $day, $month, $weekday] = $parts;
+ $time = null;
+ if (ctype_digit($minute) && ctype_digit($hour)) {
+ $time = sprintf('%02d:%02d', (int) $hour, (int) $minute);
+ }
+
+ if ($minute === '*' && $hour === '*' && $day === '*' && $month === '*' && $weekday === '*') {
+ return 'Jede Minute';
+ }
+
+ if (preg_match('/^\*\/(\d+)$/', $minute, $matches) === 1 && $hour === '*' && $day === '*' && $month === '*' && $weekday === '*') {
+ return 'Alle ' . (int) $matches[1] . ' Minuten';
+ }
+
+ if (ctype_digit($minute) && $hour === '*' && $day === '*' && $month === '*' && $weekday === '*') {
+ return 'Stuendlich um Minute ' . (int) $minute;
+ }
+
+ if (ctype_digit($minute) && preg_match('/^\*\/(\d+)$/', $hour, $matches) === 1 && $day === '*' && $month === '*' && $weekday === '*') {
+ return 'Alle ' . (int) $matches[1] . ' Stunden um ' . sprintf('%02d', (int) $minute) . ' Minuten nach voller Stunde';
+ }
+
+ if ($time !== null && $day === '*' && $month === '*' && $weekday === '*') {
+ return 'Taeglich um ' . $time;
+ }
+
+ if ($time !== null && $day === '*' && $month === '*' && $weekday === '1-5') {
+ return 'Montag bis Freitag um ' . $time;
+ }
+
+ if ($time !== null && $day === '*' && $month === '*' && preg_match('/^[0-7](,[0-7])*$/', $weekday) === 1) {
+ $days = array_map([$this, 'weekdayLabel'], explode(',', $weekday));
+ return implode(', ', array_filter($days)) . ' um ' . $time;
+ }
+
+ if ($time !== null && ctype_digit($day) && $month === '*' && $weekday === '*') {
+ return 'Monatlich am ' . (int) $day . '. um ' . $time;
+ }
+
+ if ($time !== null && ctype_digit($day) && ctype_digit($month) && $weekday === '*') {
+ return 'Jaehrlich am ' . (int) $day . '.' . sprintf('%02d', (int) $month) . ' um ' . $time;
+ }
+
+ return 'Benutzerdefiniert: ' . $expression;
+ }
+
+ private function weekdayLabel(string $value): string
+ {
+ return match ((int) trim($value)) {
+ 0, 7 => 'Sonntag',
+ 1 => 'Montag',
+ 2 => 'Dienstag',
+ 3 => 'Mittwoch',
+ 4 => 'Donnerstag',
+ 5 => 'Freitag',
+ 6 => 'Samstag',
+ default => '',
+ };
+ }
+
private function absoluteUrl(string $baseUrl, string $endpointPath): string
{
$baseUrl = rtrim(trim($baseUrl), '/');