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 {