asdsad
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-04-30 03:37:11 +02:00
parent e1b2f7e613
commit 10dc9bb0a7
4 changed files with 345 additions and 85 deletions

View File

@@ -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
<textarea name="<?= e($postKey) ?>" rows="3" <?= $required ? 'required' : '' ?>><?= e($value) ?></textarea>
<?php elseif ($type === 'checkbox'): ?>
<input type="checkbox" name="<?= e($postKey) ?>" value="1" <?= $value === '1' ? 'checked' : '' ?>>
<?php elseif ($name === 'schedule_timezone' || str_ends_with($name, '_timezone') || str_ends_with($name, '.timezone')): ?>
<input type="text" name="<?= e($postKey) ?>" value="<?= e($value) ?>" list="timezone-options" autocomplete="off" <?= $required ? 'required' : '' ?>>
<?php else: ?>
<input type="<?= e($type) ?>" name="<?= e($postKey) ?>" value="<?= e($value) ?>" <?= $required ? 'required' : '' ?>>
<?php endif; ?>
@@ -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)
<?php endif; ?>
<form method="post" class="setup-form">
<datalist id="timezone-options">
<?php foreach ($timezoneOptions as $timezoneOption): ?>
<option value="<?= e((string) $timezoneOption['value']) ?>"><?= e((string) $timezoneOption['label']) ?></option>
<?php endforeach; ?>
</datalist>
<?php if ($isFxRatesSetup): ?>
<?php
$fxCatalog = is_array($current['currency_catalog'] ?? null) ? $current['currency_catalog'] : [];
@@ -686,7 +729,7 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
<div class="setup-grid">
<label class="setup-field muted">
<span>Scheduler-Zeitzone</span>
<input type="text" name="schedule_timezone" value="<?= e((string) ($current['schedule_timezone'] ?? 'Europe/Berlin')) ?>">
<input type="text" name="schedule_timezone" value="<?= e((string) ($current['schedule_timezone'] ?? 'Europe/Berlin')) ?>" list="timezone-options" autocomplete="off">
<small class="muted">Diese Zeitzone wird fuer Cron-Jobs und die lokale Zeitberechnung verwendet.</small>
</label>
</div>
@@ -852,10 +895,9 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
<input type="number" min="1" max="31" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][month_day]" value="<?= e((string) ($builder['month_day'] ?? 1)) ?>" data-cron-builder-month-day>
<input type="number" min="1" max="23" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][interval_hours]" value="<?= e((string) ($builder['interval_hours'] ?? 6)) ?>" data-cron-builder-interval-hours>
</div>
<small class="muted">Letzter Start: <?= e($formatRunTimestamp((string) ($task['state']['last_started_at'] ?? ''))) ?></small>
<small class="muted">Letzter Erfolg: <?= e($formatRunTimestamp((string) ($task['state']['last_success_at'] ?? ''))) ?></small>
<small class="muted">Naechster Lauf UTC: <?= e($formatRunTimestamp((string) ($task['next_due_at'] ?? ''))) ?></small>
<small class="muted">Naechster Lauf lokal: <?= e((string) (($task['next_due_at_local'] ?? '') !== '' ? $task['next_due_at_local'] : '-')) ?></small>
<small class="muted">Letzter Start: <?= e($formatRunTimestamp((string) ($task['state']['last_started_at'] ?? ''), (string) ($task['timezone'] ?? 'UTC'))) ?></small>
<small class="muted">Letzter Erfolg: <?= e($formatRunTimestamp((string) ($task['state']['last_success_at'] ?? ''), (string) ($task['timezone'] ?? 'UTC'))) ?></small>
<small class="muted">Naechster Lauf lokal: <?= e(!empty($task['enabled']) ? (string) (($task['next_due_at_local'] ?? '') !== '' ? $task['next_due_at_local'] : '-') : '-') ?></small>
<small class="muted">Status: <?= e((string) (($task['state']['last_status'] ?? '') !== '' ? $task['state']['last_status'] : '-')) ?></small>
<?php if (trim((string) ($task['parse_error'] ?? '')) !== ''): ?>
<small class="muted" style="color:#b42318;">Cron-Fehler: <?= e((string) $task['parse_error']) ?></small>
@@ -1228,10 +1270,9 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
<input type="number" min="1" max="31" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][month_day]" value="<?= e((string) ($builder['month_day'] ?? 1)) ?>" data-cron-builder-month-day>
<input type="number" min="1" max="23" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][interval_hours]" value="<?= e((string) ($builder['interval_hours'] ?? 6)) ?>" data-cron-builder-interval-hours>
</div>
<small class="muted">Letzter Start: <?= e($formatRunTimestamp((string) ($task['state']['last_started_at'] ?? ''))) ?></small>
<small class="muted">Letzter Erfolg: <?= e($formatRunTimestamp((string) ($task['state']['last_success_at'] ?? ''))) ?></small>
<small class="muted">Naechster Lauf UTC: <?= e($formatRunTimestamp((string) ($task['next_due_at'] ?? ''))) ?></small>
<small class="muted">Naechster Lauf lokal: <?= e((string) (($task['next_due_at_local'] ?? '') !== '' ? $task['next_due_at_local'] : '-')) ?></small>
<small class="muted">Letzter Start: <?= e($formatRunTimestamp((string) ($task['state']['last_started_at'] ?? ''), (string) ($task['timezone'] ?? 'UTC'))) ?></small>
<small class="muted">Letzter Erfolg: <?= e($formatRunTimestamp((string) ($task['state']['last_success_at'] ?? ''), (string) ($task['timezone'] ?? 'UTC'))) ?></small>
<small class="muted">Naechster Lauf lokal: <?= e(!empty($task['enabled']) ? (string) (($task['next_due_at_local'] ?? '') !== '' ? $task['next_due_at_local'] : '-') : '-') ?></small>
<small class="muted">Status: <?= e((string) (($task['state']['last_status'] ?? '') !== '' ? $task['state']['last_status'] : '-')) ?></small>
<?php if (trim((string) ($task['parse_error'] ?? '')) !== ''): ?>
<small class="muted" style="color:#b42318;">Cron-Fehler: <?= e((string) $task['parse_error']) ?></small>
@@ -1410,7 +1451,7 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
</label>
<label class="setup-field muted">
<span>Zeitzone</span>
<input type="text" value="UTC" data-modal-timezone>
<input type="text" value="UTC" data-modal-timezone list="timezone-options" autocomplete="off">
</label>
<div class="scheduler-builder">
@@ -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 = `
<strong>${summary}</strong>
@@ -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 {

View File

@@ -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,
]);
}
}
}

View File

@@ -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(),

View File

@@ -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)) {