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();

Jobs

Erkannte Modul-Crons

+

Neue Cronjob-Typen entstehen ueber Modul-Metadaten. Ein einzelner Job kann hier angelegt, aktiviert und spaeter bearbeitet werden.

@@ -252,14 +261,35 @@ ob_start();
+ + nicht angelegt +
-
-
Status
-
Naechster Lauf
-
Letzter Erfolg
-
Endpoint
+
+
+ Status + +
+
+ Zeitplan + +
+
+ Naechster Lauf + +
+
+ Letzter Erfolg + +
+
+ +
+ Cron + Zeitzone + Endpoint
@@ -269,14 +299,32 @@ ob_start();

-
+
+ + + + + + + + + + +
+ + + + +
+ + +
+ + - -
- - - - -
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), '/');