diff --git a/partials/landingpages/modules/setup.php b/partials/landingpages/modules/setup.php index d3341d9..6d5fe40 100644 --- a/partials/landingpages/modules/setup.php +++ b/partials/landingpages/modules/setup.php @@ -42,17 +42,23 @@ foreach ($fields as $field) { } $isFxRatesSetup = $moduleName === 'fx-rates'; $current = modules()->settings($moduleName); -$intervalTaskStatuses = modules()->intervalTaskStatuses($moduleName); +$intervalTaskStatuses = []; $cronTaskDefinitions = modules()->cronTasks($moduleName); -$cronTaskStatuses = modules()->cronTaskStatuses($moduleName); +$cronTaskStatuses = []; $cronTaskStatusGroups = []; -foreach ($cronTaskStatuses as $cronTaskStatus) { - $cronGroupName = trim((string) ($cronTaskStatus['job_name'] ?? $cronTaskStatus['name'] ?? '')); - if ($cronGroupName === '') { - continue; +$refreshSchedulerState = static function () use ($moduleName, &$intervalTaskStatuses, &$cronTaskStatuses, &$cronTaskStatusGroups): void { + $intervalTaskStatuses = modules()->intervalTaskStatuses($moduleName); + $cronTaskStatuses = modules()->cronTaskStatuses($moduleName); + $cronTaskStatusGroups = []; + foreach ($cronTaskStatuses as $cronTaskStatus) { + $cronGroupName = trim((string) ($cronTaskStatus['job_name'] ?? $cronTaskStatus['name'] ?? '')); + if ($cronGroupName === '') { + continue; + } + $cronTaskStatusGroups[$cronGroupName][] = $cronTaskStatus; } - $cronTaskStatusGroups[$cronGroupName][] = $cronTaskStatus; -} +}; +$refreshSchedulerState(); $setupActions = modules()->hasFunction($moduleName, 'setup_actions') ? (array) module_fn($moduleName, 'setup_actions') : []; @@ -134,6 +140,8 @@ $driverOptions = [ 'sqlite' => 'SQLite', ]; +$timezoneOptions = modules()->timezones(); + $describeDbConfig = static function (array $dbConfig): string { $driver = (string)($dbConfig['driver'] ?? ''); $host = (string)($dbConfig['host'] ?? ''); @@ -281,18 +289,72 @@ $normalizeDriver = static function (mixed $value): mixed { }; }; -$formatRunTimestamp = static function (?string $value): string { +$formatRunTimestamp = static function (?string $value, ?string $timezone = null): string { $value = trim((string) $value); if ($value === '') { return '-'; } - $ts = strtotime($value); - if ($ts === false) { - return $value; + try { + $dt = new DateTimeImmutable($value, new DateTimeZone('UTC')); + $targetTz = trim((string) $timezone) !== '' ? new DateTimeZone((string) $timezone) : new DateTimeZone(date_default_timezone_get()); + return $dt->setTimezone($targetTz)->format('Y-m-d H:i:s'); + } catch (\Throwable) { + $ts = strtotime($value); + if ($ts === false) { + return $value; + } + return date('Y-m-d H:i:s', $ts); + } +}; + +$extractSchedulerJobs = static function (array $postedSchedulerJobs, array $cronTaskDefinitions, array $current): array { + $schedulerJobs = []; + foreach ($cronTaskDefinitions as $cronTask) { + if (!is_array($cronTask)) { + continue; + } + $cronName = trim((string) ($cronTask['name'] ?? '')); + if ($cronName === '') { + continue; + } + + $jobPayload = is_array($postedSchedulerJobs[$cronName] ?? null) ? $postedSchedulerJobs[$cronName] : []; + $entriesPayload = is_array($jobPayload['entries'] ?? null) ? $jobPayload['entries'] : []; + $entries = []; + foreach (array_values($entriesPayload) as $entryPayload) { + if (!is_array($entryPayload)) { + continue; + } + $cronExpression = trim((string) ($entryPayload['cron_expression'] ?? '')); + $timezone = trim((string) ($entryPayload['timezone'] ?? ($current['schedule_timezone'] ?? 'UTC'))); + if ($cronExpression === '' && $timezone === '') { + continue; + } + $entries[] = [ + 'enabled' => !empty($entryPayload['enabled']), + 'cron_expression' => $cronExpression, + 'timezone' => $timezone !== '' ? $timezone : 'UTC', + 'builder' => [ + 'mode' => trim((string) ($entryPayload['builder']['mode'] ?? 'builder')), + 'kind' => trim((string) ($entryPayload['builder']['kind'] ?? 'daily')), + 'time' => trim((string) ($entryPayload['builder']['time'] ?? '18:00')), + 'interval_days' => max(1, (int) ($entryPayload['builder']['interval_days'] ?? 2)), + 'weekday' => trim((string) ($entryPayload['builder']['weekday'] ?? '1')), + 'month_day' => max(1, min(31, (int) ($entryPayload['builder']['month_day'] ?? 1))), + 'interval_hours' => max(1, min(23, (int) ($entryPayload['builder']['interval_hours'] ?? 6))), + ], + ]; + } + + if ((string) ($cronTask['mode'] ?? 'single') !== 'multi' && $entries !== []) { + $entries = [array_values($entries)[0]]; + } + + $schedulerJobs[$cronName] = ['entries' => $entries]; } - return date('Y-m-d H:i:s', $ts); + return $schedulerJobs; }; $cronWeekdays = [ @@ -338,6 +400,8 @@ $renderField = function (array $field) use (&$current, $getNested, $driverOption > + + > > @@ -349,6 +413,7 @@ $renderField = function (array $field) use (&$current, $getNested, $driverOption }; if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $isSchedulerAutosave = isset($_POST['scheduler_autosave']) && (string) $_POST['scheduler_autosave'] === '1'; $payload = []; foreach ($fields as $field) { @@ -393,53 +458,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($cronTaskDefinitions !== []) { $postedSchedulerJobs = is_array($_POST['scheduler_jobs'] ?? null) ? $_POST['scheduler_jobs'] : []; - $schedulerJobs = []; - foreach ($cronTaskDefinitions as $cronTask) { - if (!is_array($cronTask)) { - continue; - } - $cronName = trim((string) ($cronTask['name'] ?? '')); - if ($cronName === '') { - continue; - } - - $jobPayload = is_array($postedSchedulerJobs[$cronName] ?? null) ? $postedSchedulerJobs[$cronName] : []; - $entriesPayload = is_array($jobPayload['entries'] ?? null) ? $jobPayload['entries'] : []; - $entries = []; - foreach (array_values($entriesPayload) as $entryPayload) { - if (!is_array($entryPayload)) { - continue; - } - $cronExpression = trim((string) ($entryPayload['cron_expression'] ?? '')); - $timezone = trim((string) ($entryPayload['timezone'] ?? ($current['schedule_timezone'] ?? 'UTC'))); - if ($cronExpression === '' && $timezone === '') { - continue; - } - $entries[] = [ - 'enabled' => !empty($entryPayload['enabled']), - 'cron_expression' => $cronExpression, - 'timezone' => $timezone !== '' ? $timezone : 'UTC', - 'builder' => [ - 'mode' => trim((string) ($entryPayload['builder']['mode'] ?? 'builder')), - 'kind' => trim((string) ($entryPayload['builder']['kind'] ?? 'daily')), - 'time' => trim((string) ($entryPayload['builder']['time'] ?? '18:00')), - 'interval_days' => max(1, (int) ($entryPayload['builder']['interval_days'] ?? 2)), - 'weekday' => trim((string) ($entryPayload['builder']['weekday'] ?? '1')), - 'month_day' => max(1, min(31, (int) ($entryPayload['builder']['month_day'] ?? 1))), - 'interval_hours' => max(1, min(23, (int) ($entryPayload['builder']['interval_hours'] ?? 6))), - ], - ]; - } - - if ((string) ($cronTask['mode'] ?? 'single') !== 'multi' && $entries !== []) { - $entries = [array_values($entries)[0]]; - } - - $schedulerJobs[$cronName] = ['entries' => $entries]; - } + $schedulerJobs = $extractSchedulerJobs($postedSchedulerJobs, $cronTaskDefinitions, $current); $current['scheduler_jobs'] = $schedulerJobs; } + if ($isSchedulerAutosave) { + modules()->saveSettings($moduleName, $current); + $current = modules()->settings($moduleName); + $refreshSchedulerState(); + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => true, + 'message' => 'Scheduler gespeichert.', + 'scheduler_jobs' => $current['scheduler_jobs'] ?? [], + 'statuses' => $cronTaskStatuses, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + return; + } + $postedTestGroup = (string)($_POST['test_db'] ?? ''); $postedResetGroup = (string)($_POST['reset_db'] ?? ''); $postedSetupAction = trim((string)($_POST['module_setup_action'] ?? '')); @@ -524,6 +561,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { module_fn($moduleName, 'save_runtime_settings', $payload); $current = modules()->settings($moduleName); } + $refreshSchedulerState(); if (empty($payload['debug_enabled'])) { module_debug_clear($moduleName); } @@ -554,6 +592,11 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
+ + + + + @@ -852,10 +895,9 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) - Letzter Start: - Letzter Erfolg: - Naechster Lauf UTC: - Naechster Lauf lokal: + Letzter Start: + Letzter Erfolg: + Naechster Lauf lokal: Status: Cron-Fehler: @@ -1228,10 +1270,9 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) - Letzter Start: - Letzter Erfolg: - Naechster Lauf UTC: - Naechster Lauf lokal: + Letzter Start: + Letzter Erfolg: + Naechster Lauf lokal: Status: Cron-Fehler: @@ -1410,7 +1451,7 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
@@ -1517,6 +1558,7 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) minute: '00', weekday: '1', }; + const form = modal.closest('form'); const modalFields = { enabled: modal.querySelector('[data-modal-enabled]'), @@ -1642,6 +1684,78 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) }; const getEntryField = (entry, selector) => entry.querySelector(selector); + const findStatusNode = (entry, label) => Array.from(entry.querySelectorAll('small')).find((node) => node.textContent.includes(label)); + const setStatusText = (entry, label, value) => { + const node = findStatusNode(entry, label); + if (node) { + node.textContent = `${label}: ${value}`; + } + }; + + const applyStatusUpdates = (statuses) => { + if (!Array.isArray(statuses)) return; + const statusMap = new Map(); + statuses.forEach((status) => { + statusMap.set(`${status.job_name}#${status.entry_index}`, status); + }); + + jobs.forEach((job) => { + const jobName = job.dataset.jobName || ''; + job.querySelectorAll('[data-scheduler-entry]').forEach((entry) => { + const key = `${jobName}#${entry.dataset.entryIndex || '0'}`; + const status = statusMap.get(key); + if (!status) return; + const enabledNode = getEntryField(entry, '[data-enabled]'); + if (enabledNode) { + enabledNode.checked = !!status.enabled; + } + setStatusText(entry, 'Letzter Start', status.last_started_at_local || status.state?.last_started_at || '-'); + setStatusText(entry, 'Letzter Erfolg', status.last_success_at_local || status.state?.last_success_at || '-'); + setStatusText(entry, 'Naechster Lauf lokal', status.enabled ? (status.next_due_at_local || '-') : '-'); + setStatusText(entry, 'Status', status.state?.last_status || '-'); + updateEntrySummary(entry); + }); + }); + }; + + const collectSchedulerPayload = () => { + const payload = new FormData(); + payload.append('scheduler_autosave', '1'); + jobs.forEach((job) => { + job.querySelectorAll('[data-scheduler-entry]').forEach((entry) => { + entry.querySelectorAll('[name]').forEach((field) => { + if (field instanceof HTMLInputElement && field.type === 'checkbox') { + if (field.checked) { + payload.append(field.name, field.value || '1'); + } + return; + } + payload.append(field.name, field.value); + }); + }); + }); + return payload; + }; + + const persistScheduler = async () => { + if (!form) return true; + const response = await fetch(window.location.href, { + method: 'POST', + body: collectSchedulerPayload(), + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + if (!response.ok) { + throw new Error(`Scheduler konnte nicht gespeichert werden (${response.status}).`); + } + const data = await response.json(); + if (!data || data.ok !== true) { + throw new Error(data?.message || 'Scheduler konnte nicht gespeichert werden.'); + } + applyStatusUpdates(data.statuses || []); + return true; + }; const updateEntrySummary = (entry) => { const enabled = getEntryField(entry, '[data-enabled]')?.checked ?? false; @@ -1683,11 +1797,11 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) entry.appendChild(statusNode); } - const lastStart = Array.from(entry.querySelectorAll('small')).find((node) => node.textContent.includes('Letzter Start')); - const lastSuccess = Array.from(entry.querySelectorAll('small')).find((node) => node.textContent.includes('Letzter Erfolg')); - const nextLocal = Array.from(entry.querySelectorAll('small')).find((node) => node.textContent.includes('Naechster Lauf lokal')); - const status = Array.from(entry.querySelectorAll('small')).find((node) => node.textContent.includes('Status:')); - const parseError = Array.from(entry.querySelectorAll('small')).find((node) => node.textContent.includes('Cron-Fehler')); + const lastStart = findStatusNode(entry, 'Letzter Start'); + const lastSuccess = findStatusNode(entry, 'Letzter Erfolg'); + const nextLocal = findStatusNode(entry, 'Naechster Lauf lokal'); + const status = findStatusNode(entry, 'Status'); + const parseError = findStatusNode(entry, 'Cron-Fehler'); summaryNode.innerHTML = ` ${summary} @@ -1755,7 +1869,7 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) document.body.classList.remove('scheduler-modal-open'); }; - const saveModal = () => { + const saveModal = async () => { const entry = modalState.entry; if (!entry) return; @@ -1794,7 +1908,12 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) } updateEntrySummary(entry); - closeModal(); + try { + await persistScheduler(); + closeModal(); + } catch (error) { + alert(error instanceof Error ? error.message : 'Scheduler konnte nicht gespeichert werden.'); + } }; const createEntry = (job, values = {}) => { @@ -1850,6 +1969,7 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) const reindexJob = (job) => { const jobName = job.dataset.jobName || 'job'; job.querySelectorAll('[data-scheduler-entry]').forEach((entry, index) => { + entry.dataset.entryIndex = String(index); const setName = (selector, suffix) => { const node = entry.querySelector(selector); if (node) { @@ -1886,9 +2006,14 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) } actions.querySelector('[data-entry-edit]')?.addEventListener('click', () => openModal(entry)); - actions.querySelector('[data-remove-scheduler-entry]')?.addEventListener('click', () => { + actions.querySelector('[data-remove-scheduler-entry]')?.addEventListener('click', async () => { entry.remove(); reindexJob(job); + try { + await persistScheduler(); + } catch (error) { + alert(error instanceof Error ? error.message : 'Scheduler konnte nicht gespeichert werden.'); + } }); }; @@ -1997,7 +2122,7 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups) .scheduler-modal__backdrop { position: absolute; inset: 0; - background: rgba(10, 18, 28, 0.45); + background: #ffffff; } .scheduler-modal__dialog { diff --git a/src/App/BaseSchema.php b/src/App/BaseSchema.php index 536ea39..43753dd 100644 --- a/src/App/BaseSchema.php +++ b/src/App/BaseSchema.php @@ -10,13 +10,16 @@ final class BaseSchema $driver = (string)$pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); if ($driver === 'pgsql') { self::ensurePgsql($pdo); + self::seedTimezones($pdo); return; } if ($driver === 'sqlite') { self::ensureSqlite($pdo); + self::seedTimezones($pdo); return; } self::ensureGeneric($pdo); + self::seedTimezones($pdo); } private static function ensurePgsql(\PDO $pdo): void @@ -136,6 +139,15 @@ final class BaseSchema updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )" ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_timezones ( + identifier TEXT PRIMARY KEY, + label TEXT NOT NULL, + group_name TEXT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )" + ); } private static function ensureSqlite(\PDO $pdo): void @@ -255,6 +267,15 @@ final class BaseSchema updated_at TEXT NOT NULL DEFAULT (datetime('now')) )" ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_timezones ( + identifier TEXT PRIMARY KEY, + label TEXT NOT NULL, + group_name TEXT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); } private static function ensureGeneric(\PDO $pdo): void @@ -374,5 +395,66 @@ final class BaseSchema updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )" ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_timezones ( + identifier VARCHAR(190) PRIMARY KEY, + label VARCHAR(255) NOT NULL, + group_name VARCHAR(190) NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )" + ); + } + + private static function seedTimezones(\PDO $pdo): void + { + try { + $count = (int) $pdo->query("SELECT COUNT(*) FROM nexus_timezones")->fetchColumn(); + } catch (\Throwable) { + return; + } + + if ($count > 0) { + return; + } + + $driver = (string) $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + $sql = match ($driver) { + 'pgsql' => "INSERT INTO nexus_timezones (identifier, label, group_name, updated_at) + VALUES (:identifier, :label, :group_name, NOW()) + ON CONFLICT (identifier) DO UPDATE SET + label = EXCLUDED.label, + group_name = EXCLUDED.group_name, + updated_at = NOW()", + 'sqlite' => "INSERT INTO nexus_timezones (identifier, label, group_name, updated_at) + VALUES (:identifier, :label, :group_name, datetime('now')) + ON CONFLICT(identifier) DO UPDATE SET + label = excluded.label, + group_name = excluded.group_name, + updated_at = datetime('now')", + default => "INSERT INTO nexus_timezones (identifier, label, group_name, updated_at) + VALUES (:identifier, :label, :group_name, CURRENT_TIMESTAMP) + ON DUPLICATE KEY UPDATE + label = VALUES(label), + group_name = VALUES(group_name), + updated_at = CURRENT_TIMESTAMP", + }; + + $stmt = $pdo->prepare($sql); + foreach (timezone_identifiers_list() as $identifier) { + $parts = explode('/', $identifier, 2); + $group = $parts[0] ?? 'Other'; + $label = str_replace('_', ' ', $identifier); + if ($identifier === 'UTC') { + $group = 'UTC'; + $label = 'UTC'; + } + + $stmt->execute([ + 'identifier' => $identifier, + 'label' => $label, + 'group_name' => $group, + ]); + } } } diff --git a/src/App/ModuleCronScheduler.php b/src/App/ModuleCronScheduler.php index 07dd106..3d679d8 100644 --- a/src/App/ModuleCronScheduler.php +++ b/src/App/ModuleCronScheduler.php @@ -75,15 +75,16 @@ final class ModuleCronScheduler try { $cron = CronExpression::parse($expression); - $previousDueUtc = $cron->previousRun($nowUtc, $timezone); - $lastScheduledFor = $this->parseUtc((string) ($state['last_scheduled_for'] ?? '')); - $isDue = !empty($config['enabled']) - && !$isLocked - && $previousDueUtc instanceof DateTimeImmutable - && ($lastScheduledFor === null || $previousDueUtc > $lastScheduledFor); - $nextDueUtc = $isDue - ? $previousDueUtc - : $cron->nextRun($nowUtc, $timezone); + if (!empty($config['enabled'])) { + $previousDueUtc = $cron->previousRun($nowUtc, $timezone); + $lastScheduledFor = $this->parseUtc((string) ($state['last_scheduled_for'] ?? '')); + $isDue = !$isLocked + && $previousDueUtc instanceof DateTimeImmutable + && ($lastScheduledFor === null || $previousDueUtc > $lastScheduledFor); + $nextDueUtc = $isDue + ? $previousDueUtc + : $cron->nextRun($nowUtc, $timezone); + } } catch (\Throwable $exception) { $parseError = $exception->getMessage(); } @@ -94,6 +95,8 @@ final class ModuleCronScheduler 'state_key' => $stateKey, 'config' => $config, 'state' => $state, + 'last_started_at_local' => ($this->parseUtc((string) ($state['last_started_at'] ?? '')))?->setTimezone($timezone)->format('Y-m-d H:i:s'), + 'last_success_at_local' => ($this->parseUtc((string) ($state['last_success_at'] ?? '')))?->setTimezone($timezone)->format('Y-m-d H:i:s'), 'enabled' => !empty($config['enabled']), 'cron_expression' => $expression, 'timezone' => $timezone->getName(), diff --git a/src/App/ModuleManager.php b/src/App/ModuleManager.php index c820fc2..2b76127 100644 --- a/src/App/ModuleManager.php +++ b/src/App/ModuleManager.php @@ -128,6 +128,42 @@ final class ModuleManager ]); } + public function timezones(): array + { + if (!$this->basePdo) { + return $this->fallbackTimezones(); + } + + try { + $stmt = $this->basePdo->query( + "SELECT identifier, label, group_name + FROM nexus_timezones + ORDER BY group_name ASC, identifier ASC" + ); + $rows = $stmt ? $stmt->fetchAll(\PDO::FETCH_ASSOC) : []; + if (!is_array($rows) || $rows === []) { + return $this->fallbackTimezones(); + } + + return array_values(array_filter(array_map( + static function (array $row): ?array { + $identifier = trim((string) ($row['identifier'] ?? '')); + if ($identifier === '') { + return null; + } + return [ + 'value' => $identifier, + 'label' => trim((string) ($row['label'] ?? $identifier)), + 'group' => trim((string) ($row['group_name'] ?? '')), + ]; + }, + $rows + ))); + } catch (\Throwable) { + return $this->fallbackTimezones(); + } + } + public function modulePdo(string $name, array $fallback = []): ?\PDO { $settings = $this->settings($name); @@ -358,6 +394,20 @@ final class ModuleManager return $enabledByDefault; } + private function fallbackTimezones(): array + { + $result = []; + foreach (timezone_identifiers_list() as $identifier) { + $parts = explode('/', $identifier, 2); + $result[] = [ + 'value' => $identifier, + 'label' => $identifier === 'UTC' ? 'UTC' : str_replace('_', ' ', $identifier), + 'group' => $parts[0] ?? 'Other', + ]; + } + return $result; + } + public function saveAuth(string $name, array $auth): array { if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) {