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

This commit is contained in:
2026-04-30 03:00:21 +02:00
parent 0c5c89acfa
commit 8879a4ae5c
5 changed files with 468 additions and 187 deletions

View File

@@ -25,11 +25,12 @@
{ "name": "schedule_timezone", "label": "Scheduler-Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" }
]
},
"cron_tasks": [
"scheduler_jobs": [
{
"name": "rates_refresh",
"label": "Kursabruf",
"callback": "scheduled_refresh",
"mode": "multi",
"default_enabled": true,
"default_cron": "0 18 * * *",
"default_timezone": "Europe/Berlin",

View File

@@ -45,6 +45,14 @@ $current = modules()->settings($moduleName);
$intervalTaskStatuses = modules()->intervalTaskStatuses($moduleName);
$cronTaskDefinitions = modules()->cronTasks($moduleName);
$cronTaskStatuses = modules()->cronTaskStatuses($moduleName);
$cronTaskStatusGroups = [];
foreach ($cronTaskStatuses as $cronTaskStatus) {
$cronGroupName = trim((string) ($cronTaskStatus['job_name'] ?? $cronTaskStatus['name'] ?? ''));
if ($cronGroupName === '') {
continue;
}
$cronTaskStatusGroups[$cronGroupName][] = $cronTaskStatus;
}
$setupActions = modules()->hasFunction($moduleName, 'setup_actions')
? (array) module_fn($moduleName, 'setup_actions')
: [];
@@ -384,7 +392,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$current = array_replace_recursive($current, $payload);
if ($cronTaskDefinitions !== []) {
$cronJobs = is_array($current['cron_jobs'] ?? null) ? $current['cron_jobs'] : [];
$postedSchedulerJobs = is_array($_POST['scheduler_jobs'] ?? null) ? $_POST['scheduler_jobs'] : [];
$schedulerJobs = [];
foreach ($cronTaskDefinitions as $cronTask) {
if (!is_array($cronTask)) {
continue;
@@ -394,26 +403,41 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
continue;
}
$prefix = 'cron_job_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $cronName);
$cronJobs[$cronName] = array_merge(
is_array($cronJobs[$cronName] ?? null) ? $cronJobs[$cronName] : [],
[
'enabled' => isset($_POST[$prefix . '_enabled']),
'cron_expression' => trim((string) ($_POST[$prefix . '_cron_expression'] ?? ($cronTask['default_cron'] ?? ''))),
'timezone' => trim((string) ($_POST[$prefix . '_timezone'] ?? ($cronTask['default_timezone'] ?? 'UTC'))),
'builder' => [
'mode' => trim((string) ($_POST[$prefix . '_builder_mode'] ?? 'builder')),
'kind' => trim((string) ($_POST[$prefix . '_builder_kind'] ?? 'daily')),
'time' => trim((string) ($_POST[$prefix . '_builder_time'] ?? '18:00')),
'interval_days' => max(1, (int) ($_POST[$prefix . '_builder_interval_days'] ?? 2)),
'weekday' => trim((string) ($_POST[$prefix . '_builder_weekday'] ?? '1')),
'month_day' => max(1, min(31, (int) ($_POST[$prefix . '_builder_month_day'] ?? 1))),
'interval_hours' => max(1, (int) ($_POST[$prefix . '_builder_interval_hours'] ?? 6)),
],
]
);
$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;
}
$current['cron_jobs'] = $cronJobs;
$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];
}
$current['scheduler_jobs'] = $schedulerJobs;
}
$postedTestGroup = (string)($_POST['test_db'] ?? '');
@@ -774,7 +798,7 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
</section>
<?php endif; ?>
<?php if ($cronTaskStatuses !== []): ?>
<?php if ($cronTaskDefinitions !== []): ?>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
@@ -783,44 +807,50 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
<p class="muted">Diese Jobs werden ueber den zentralen Nexus-Scheduler ausgefuehrt. Der System-Cron sollte den CLI-Runner jede Minute starten.</p>
</div>
</div>
<div class="setup-grid">
<?php foreach ($cronTaskStatuses as $task): ?>
<?php foreach ($cronTaskDefinitions as $cronDefinition): ?>
<?php
$cronName = trim((string) ($cronDefinition['name'] ?? ''));
if ($cronName === '') { continue; }
$cronMode = (string) ($cronDefinition['mode'] ?? 'single');
$cronEntries = $cronTaskStatusGroups[$cronName] ?? [];
?>
<div class="setup-field muted" data-scheduler-job data-job-name="<?= e($cronName) ?>" data-job-mode="<?= e($cronMode) ?>">
<span><?= e((string) ($cronDefinition['label'] ?? $cronName)) ?></span>
<?php if (trim((string) ($cronDefinition['help'] ?? '')) !== ''): ?>
<small class="muted"><?= e((string) $cronDefinition['help']) ?></small>
<?php endif; ?>
<div class="scheduler-entries" data-scheduler-entries>
<?php foreach (array_values($cronEntries) as $entryIndex => $task): ?>
<?php
$cronName = trim((string) ($task['name'] ?? ''));
$cronConfig = is_array($task['config'] ?? null) ? $task['config'] : [];
$builder = is_array($cronConfig['builder'] ?? null) ? $cronConfig['builder'] : [];
$prefix = 'cron_job_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $cronName);
?>
<div class="setup-field muted" data-cron-job>
<span><?= e((string) ($task['label'] ?? $cronName)) ?></span>
<?php if (trim((string) ($task['help'] ?? '')) !== ''): ?>
<small class="muted"><?= e((string) $task['help']) ?></small>
<?php endif; ?>
<label><input type="checkbox" name="<?= e($prefix) ?>_enabled" value="1" <?= !empty($cronConfig['enabled']) ? 'checked' : '' ?>> Aktiv</label>
<input type="text" name="<?= e($prefix) ?>_cron_expression" value="<?= e((string) ($cronConfig['cron_expression'] ?? '')) ?>" data-cron-expression>
<div class="scheduler-entry" data-scheduler-entry>
<label><input type="checkbox" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][enabled]" value="1" <?= !empty($cronConfig['enabled']) ? 'checked' : '' ?>> Aktiv</label>
<input type="text" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][cron_expression]" value="<?= e((string) ($cronConfig['cron_expression'] ?? '')) ?>" data-cron-expression>
<small class="muted">Cron-Syntax: Minute Stunde Tag Monat Wochentag</small>
<input type="text" name="<?= e($prefix) ?>_timezone" value="<?= e((string) ($cronConfig['timezone'] ?? 'UTC')) ?>" data-cron-timezone>
<select name="<?= e($prefix) ?>_builder_mode" data-cron-builder-mode>
<input type="text" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][timezone]" value="<?= e((string) ($cronConfig['timezone'] ?? 'UTC')) ?>" data-cron-timezone>
<select name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][mode]" data-cron-builder-mode>
<option value="builder" <?= (string) ($builder['mode'] ?? 'builder') === 'builder' ? 'selected' : '' ?>>Builder</option>
<option value="manual" <?= (string) ($builder['mode'] ?? 'builder') === 'manual' ? 'selected' : '' ?>>Cron-Syntax</option>
</select>
<div data-cron-builder-fields>
<select name="<?= e($prefix) ?>_builder_kind" data-cron-builder-kind>
<select name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][kind]" data-cron-builder-kind>
<option value="daily" <?= (string) ($builder['kind'] ?? 'daily') === 'daily' ? 'selected' : '' ?>>Taeglich</option>
<option value="every_x_days" <?= (string) ($builder['kind'] ?? '') === 'every_x_days' ? 'selected' : '' ?>>Alle x Tage</option>
<option value="weekly" <?= (string) ($builder['kind'] ?? '') === 'weekly' ? 'selected' : '' ?>>Woechentlich</option>
<option value="monthly_day" <?= (string) ($builder['kind'] ?? '') === 'monthly_day' ? 'selected' : '' ?>>X-Tag im Monat</option>
<option value="every_x_hours" <?= (string) ($builder['kind'] ?? '') === 'every_x_hours' ? 'selected' : '' ?>>Alle x Stunden</option>
</select>
<input type="time" name="<?= e($prefix) ?>_builder_time" value="<?= e((string) ($builder['time'] ?? '18:00')) ?>" data-cron-builder-time>
<input type="number" min="1" name="<?= e($prefix) ?>_builder_interval_days" value="<?= e((string) ($builder['interval_days'] ?? 2)) ?>" data-cron-builder-interval-days>
<select name="<?= e($prefix) ?>_builder_weekday" data-cron-builder-weekday>
<input type="time" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][time]" value="<?= e((string) ($builder['time'] ?? '18:00')) ?>" data-cron-builder-time>
<input type="number" min="1" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][interval_days]" value="<?= e((string) ($builder['interval_days'] ?? 2)) ?>" data-cron-builder-interval-days>
<select name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][weekday]" data-cron-builder-weekday>
<?php foreach ($cronWeekdays as $weekdayValue => $weekdayLabel): ?>
<option value="<?= e($weekdayValue) ?>" <?= (string) ($builder['weekday'] ?? '1') === $weekdayValue ? 'selected' : '' ?>><?= e($weekdayLabel) ?></option>
<?php endforeach; ?>
</select>
<input type="number" min="1" max="31" name="<?= e($prefix) ?>_builder_month_day" value="<?= e((string) ($builder['month_day'] ?? 1)) ?>" data-cron-builder-month-day>
<input type="number" min="1" max="23" name="<?= e($prefix) ?>_builder_interval_hours" value="<?= e((string) ($builder['interval_hours'] ?? 6)) ?>" data-cron-builder-interval-hours>
<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>
@@ -830,9 +860,19 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
<?php if (trim((string) ($task['parse_error'] ?? '')) !== ''): ?>
<small class="muted" style="color:#b42318;">Cron-Fehler: <?= e((string) $task['parse_error']) ?></small>
<?php endif; ?>
<?php if ($cronMode === 'multi'): ?>
<button class="nav-link" type="button" data-remove-scheduler-entry>Eintrag entfernen</button>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php if ($cronMode === 'multi'): ?>
<div class="setup-actions" style="justify-content:flex-start; margin-top:12px;">
<button class="nav-link" type="button" data-add-scheduler-entry>Weiteren Cron hinzufügen</button>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</section>
<?php endif; ?>
@@ -1134,7 +1174,7 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
</section>
<?php endif; ?>
<?php if ($cronTaskStatuses !== []): ?>
<?php if ($cronTaskDefinitions !== []): ?>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
@@ -1143,44 +1183,50 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
<p class="muted">Diese Jobs werden ueber den zentralen Nexus-Scheduler ausgefuehrt. Der System-Cron sollte den CLI-Runner jede Minute starten.</p>
</div>
</div>
<div class="setup-grid">
<?php foreach ($cronTaskStatuses as $task): ?>
<?php foreach ($cronTaskDefinitions as $cronDefinition): ?>
<?php
$cronName = trim((string) ($cronDefinition['name'] ?? ''));
if ($cronName === '') { continue; }
$cronMode = (string) ($cronDefinition['mode'] ?? 'single');
$cronEntries = $cronTaskStatusGroups[$cronName] ?? [];
?>
<div class="setup-field muted" data-scheduler-job data-job-name="<?= e($cronName) ?>" data-job-mode="<?= e($cronMode) ?>">
<span><?= e((string) ($cronDefinition['label'] ?? $cronName)) ?></span>
<?php if (trim((string) ($cronDefinition['help'] ?? '')) !== ''): ?>
<small class="muted"><?= e((string) $cronDefinition['help']) ?></small>
<?php endif; ?>
<div class="scheduler-entries" data-scheduler-entries>
<?php foreach (array_values($cronEntries) as $entryIndex => $task): ?>
<?php
$cronName = trim((string) ($task['name'] ?? ''));
$cronConfig = is_array($task['config'] ?? null) ? $task['config'] : [];
$builder = is_array($cronConfig['builder'] ?? null) ? $cronConfig['builder'] : [];
$prefix = 'cron_job_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $cronName);
?>
<div class="setup-field muted" data-cron-job>
<span><?= e((string) ($task['label'] ?? $cronName)) ?></span>
<?php if (trim((string) ($task['help'] ?? '')) !== ''): ?>
<small class="muted"><?= e((string) $task['help']) ?></small>
<?php endif; ?>
<label><input type="checkbox" name="<?= e($prefix) ?>_enabled" value="1" <?= !empty($cronConfig['enabled']) ? 'checked' : '' ?>> Aktiv</label>
<input type="text" name="<?= e($prefix) ?>_cron_expression" value="<?= e((string) ($cronConfig['cron_expression'] ?? '')) ?>" data-cron-expression>
<div class="scheduler-entry" data-scheduler-entry>
<label><input type="checkbox" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][enabled]" value="1" <?= !empty($cronConfig['enabled']) ? 'checked' : '' ?>> Aktiv</label>
<input type="text" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][cron_expression]" value="<?= e((string) ($cronConfig['cron_expression'] ?? '')) ?>" data-cron-expression>
<small class="muted">Cron-Syntax: Minute Stunde Tag Monat Wochentag</small>
<input type="text" name="<?= e($prefix) ?>_timezone" value="<?= e((string) ($cronConfig['timezone'] ?? 'UTC')) ?>" data-cron-timezone>
<select name="<?= e($prefix) ?>_builder_mode" data-cron-builder-mode>
<input type="text" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][timezone]" value="<?= e((string) ($cronConfig['timezone'] ?? 'UTC')) ?>" data-cron-timezone>
<select name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][mode]" data-cron-builder-mode>
<option value="builder" <?= (string) ($builder['mode'] ?? 'builder') === 'builder' ? 'selected' : '' ?>>Builder</option>
<option value="manual" <?= (string) ($builder['mode'] ?? 'builder') === 'manual' ? 'selected' : '' ?>>Cron-Syntax</option>
</select>
<div data-cron-builder-fields>
<select name="<?= e($prefix) ?>_builder_kind" data-cron-builder-kind>
<select name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][kind]" data-cron-builder-kind>
<option value="daily" <?= (string) ($builder['kind'] ?? 'daily') === 'daily' ? 'selected' : '' ?>>Taeglich</option>
<option value="every_x_days" <?= (string) ($builder['kind'] ?? '') === 'every_x_days' ? 'selected' : '' ?>>Alle x Tage</option>
<option value="weekly" <?= (string) ($builder['kind'] ?? '') === 'weekly' ? 'selected' : '' ?>>Woechentlich</option>
<option value="monthly_day" <?= (string) ($builder['kind'] ?? '') === 'monthly_day' ? 'selected' : '' ?>>X-Tag im Monat</option>
<option value="every_x_hours" <?= (string) ($builder['kind'] ?? '') === 'every_x_hours' ? 'selected' : '' ?>>Alle x Stunden</option>
</select>
<input type="time" name="<?= e($prefix) ?>_builder_time" value="<?= e((string) ($builder['time'] ?? '18:00')) ?>" data-cron-builder-time>
<input type="number" min="1" name="<?= e($prefix) ?>_builder_interval_days" value="<?= e((string) ($builder['interval_days'] ?? 2)) ?>" data-cron-builder-interval-days>
<select name="<?= e($prefix) ?>_builder_weekday" data-cron-builder-weekday>
<input type="time" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][time]" value="<?= e((string) ($builder['time'] ?? '18:00')) ?>" data-cron-builder-time>
<input type="number" min="1" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][interval_days]" value="<?= e((string) ($builder['interval_days'] ?? 2)) ?>" data-cron-builder-interval-days>
<select name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][weekday]" data-cron-builder-weekday>
<?php foreach ($cronWeekdays as $weekdayValue => $weekdayLabel): ?>
<option value="<?= e($weekdayValue) ?>" <?= (string) ($builder['weekday'] ?? '1') === $weekdayValue ? 'selected' : '' ?>><?= e($weekdayLabel) ?></option>
<?php endforeach; ?>
</select>
<input type="number" min="1" max="31" name="<?= e($prefix) ?>_builder_month_day" value="<?= e((string) ($builder['month_day'] ?? 1)) ?>" data-cron-builder-month-day>
<input type="number" min="1" max="23" name="<?= e($prefix) ?>_builder_interval_hours" value="<?= e((string) ($builder['interval_hours'] ?? 6)) ?>" data-cron-builder-interval-hours>
<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>
@@ -1190,9 +1236,19 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
<?php if (trim((string) ($task['parse_error'] ?? '')) !== ''): ?>
<small class="muted" style="color:#b42318;">Cron-Fehler: <?= e((string) $task['parse_error']) ?></small>
<?php endif; ?>
<?php if ($cronMode === 'multi'): ?>
<button class="nav-link" type="button" data-remove-scheduler-entry>Eintrag entfernen</button>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php if ($cronMode === 'multi'): ?>
<div class="setup-actions" style="justify-content:flex-start; margin-top:12px;">
<button class="nav-link" type="button" data-add-scheduler-entry>Weiteren Cron hinzufügen</button>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</section>
<?php endif; ?>
@@ -1336,9 +1392,19 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
<?php endif; ?>
<script>
(() => {
const jobs = document.querySelectorAll('[data-cron-job]');
const jobs = document.querySelectorAll('[data-scheduler-job]');
if (!jobs.length) return;
const weekdayOptions = [
['0', 'Sonntag'],
['1', 'Montag'],
['2', 'Dienstag'],
['3', 'Mittwoch'],
['4', 'Donnerstag'],
['5', 'Freitag'],
['6', 'Samstag'],
];
const buildExpression = (kind, values) => {
const [hourRaw, minuteRaw] = String(values.time || '18:00').split(':');
const minute = Math.max(0, Math.min(59, Number(minuteRaw || 0)));
@@ -1363,17 +1429,92 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
}
};
jobs.forEach((job) => {
const expression = job.querySelector('[data-cron-expression]');
const mode = job.querySelector('[data-cron-builder-mode]');
const fields = job.querySelector('[data-cron-builder-fields]');
const kind = job.querySelector('[data-cron-builder-kind]');
const time = job.querySelector('[data-cron-builder-time]');
const intervalDays = job.querySelector('[data-cron-builder-interval-days]');
const weekday = job.querySelector('[data-cron-builder-weekday]');
const monthDay = job.querySelector('[data-cron-builder-month-day]');
const intervalHours = job.querySelector('[data-cron-builder-interval-hours]');
const weekdayMarkup = () => weekdayOptions.map(([value, label]) => (
`<option value="${value}">${label}</option>`
)).join('');
const createEntry = (job, values = {}) => {
const jobName = job.dataset.jobName || 'job';
const entry = document.createElement('div');
entry.className = 'scheduler-entry';
entry.dataset.schedulerEntry = '';
entry.innerHTML = `
<label><input type="checkbox" value="1" data-enabled> Aktiv</label>
<input type="text" value="" data-cron-expression>
<small class="muted">Cron-Syntax: Minute Stunde Tag Monat Wochentag</small>
<input type="text" value="" data-cron-timezone>
<select data-cron-builder-mode>
<option value="builder">Builder</option>
<option value="manual">Cron-Syntax</option>
</select>
<div data-cron-builder-fields>
<select data-cron-builder-kind>
<option value="daily">Taeglich</option>
<option value="every_x_days">Alle x Tage</option>
<option value="weekly">Woechentlich</option>
<option value="monthly_day">X-Tag im Monat</option>
<option value="every_x_hours">Alle x Stunden</option>
</select>
<input type="time" value="18:00" data-cron-builder-time>
<input type="number" min="1" value="2" data-cron-builder-interval-days>
<select data-cron-builder-weekday>${weekdayMarkup()}</select>
<input type="number" min="1" max="31" value="1" data-cron-builder-month-day>
<input type="number" min="1" max="23" value="6" data-cron-builder-interval-hours>
</div>
<small class="muted">Letzter Start: -</small>
<small class="muted">Letzter Erfolg: -</small>
<small class="muted">Naechster Lauf UTC: -</small>
<small class="muted">Naechster Lauf lokal: -</small>
<small class="muted">Status: -</small>
${job.dataset.jobMode === 'multi' ? '<button class="nav-link" type="button" data-remove-scheduler-entry>Eintrag entfernen</button>' : ''}
`;
entry.querySelector('[data-enabled]').checked = Boolean(values.enabled);
entry.querySelector('[data-cron-expression]').value = values.cron_expression || '0 18 * * *';
entry.querySelector('[data-cron-timezone]').value = values.timezone || 'UTC';
entry.querySelector('[data-cron-builder-mode]').value = values.builderMode || 'builder';
entry.querySelector('[data-cron-builder-kind]').value = values.builderKind || 'daily';
entry.querySelector('[data-cron-builder-time]').value = values.time || '18:00';
entry.querySelector('[data-cron-builder-interval-days]').value = values.intervalDays || '2';
entry.querySelector('[data-cron-builder-weekday]').value = values.weekday || '1';
entry.querySelector('[data-cron-builder-month-day]').value = values.monthDay || '1';
entry.querySelector('[data-cron-builder-interval-hours]').value = values.intervalHours || '6';
return entry;
};
const reindexJob = (job) => {
const jobName = job.dataset.jobName || 'job';
job.querySelectorAll('[data-scheduler-entry]').forEach((entry, index) => {
const setName = (selector, suffix) => {
const node = entry.querySelector(selector);
if (node) {
node.name = `scheduler_jobs[${jobName}][entries][${index}]${suffix}`;
}
};
setName('[data-enabled]', '[enabled]');
setName('[data-cron-expression]', '[cron_expression]');
setName('[data-cron-timezone]', '[timezone]');
setName('[data-cron-builder-mode]', '[builder][mode]');
setName('[data-cron-builder-kind]', '[builder][kind]');
setName('[data-cron-builder-time]', '[builder][time]');
setName('[data-cron-builder-interval-days]', '[builder][interval_days]');
setName('[data-cron-builder-weekday]', '[builder][weekday]');
setName('[data-cron-builder-month-day]', '[builder][month_day]');
setName('[data-cron-builder-interval-hours]', '[builder][interval_hours]');
});
};
const bindEntry = (entry) => {
const expression = entry.querySelector('[data-cron-expression]');
const mode = entry.querySelector('[data-cron-builder-mode]');
const fields = entry.querySelector('[data-cron-builder-fields]');
const kind = entry.querySelector('[data-cron-builder-kind]');
const time = entry.querySelector('[data-cron-builder-time]');
const intervalDays = entry.querySelector('[data-cron-builder-interval-days]');
const weekday = entry.querySelector('[data-cron-builder-weekday]');
const monthDay = entry.querySelector('[data-cron-builder-month-day]');
const intervalHours = entry.querySelector('[data-cron-builder-interval-hours]');
if (!expression || !mode || !fields || !kind || !time || !intervalDays || !weekday || !monthDay || !intervalHours) {
return;
}
@@ -1398,11 +1539,65 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
node.addEventListener('input', sync);
});
const removeButton = entry.querySelector('[data-remove-scheduler-entry]');
if (removeButton) {
removeButton.addEventListener('click', () => {
const job = entry.closest('[data-scheduler-job]');
if (!job) return;
entry.remove();
reindexJob(job);
});
}
sync();
};
jobs.forEach((job) => {
job.querySelectorAll('[data-scheduler-entry]').forEach((entry) => bindEntry(entry));
reindexJob(job);
const addButton = job.querySelector('[data-add-scheduler-entry]');
if (!addButton) return;
addButton.addEventListener('click', () => {
const container = job.querySelector('[data-scheduler-entries]');
if (!container) return;
const source = container.querySelector('[data-scheduler-entry]');
const values = source ? {
timezone: source.querySelector('[data-cron-timezone]')?.value || 'UTC',
builderMode: source.querySelector('[data-cron-builder-mode]')?.value || 'builder',
builderKind: source.querySelector('[data-cron-builder-kind]')?.value || 'daily',
time: source.querySelector('[data-cron-builder-time]')?.value || '18:00',
intervalDays: source.querySelector('[data-cron-builder-interval-days]')?.value || '2',
weekday: source.querySelector('[data-cron-builder-weekday]')?.value || '1',
monthDay: source.querySelector('[data-cron-builder-month-day]')?.value || '1',
intervalHours: source.querySelector('[data-cron-builder-interval-hours]')?.value || '6',
} : {};
const entry = createEntry(job, values);
container.appendChild(entry);
bindEntry(entry);
reindexJob(job);
});
});
})();
</script>
<style>
.scheduler-entries {
display: grid;
gap: 12px;
}
.scheduler-entry {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: color-mix(in srgb, var(--surface) 88%, white);
}
[data-cron-builder-fields] {
display: grid;
gap: 10px;

View File

@@ -17,7 +17,7 @@ final class ModuleCronScheduler
public function definitions(string $moduleName): array
{
$module = $this->modules->get($moduleName);
$tasks = is_array($module['cron_tasks'] ?? null) ? $module['cron_tasks'] : [];
$tasks = is_array($module['scheduler_jobs'] ?? null) ? $module['scheduler_jobs'] : (is_array($module['cron_tasks'] ?? null) ? $module['cron_tasks'] : []);
$definitions = [];
foreach ($tasks as $task) {
@@ -35,6 +35,7 @@ final class ModuleCronScheduler
'name' => $name,
'label' => trim((string) ($task['label'] ?? $name)),
'callback' => $callback,
'mode' => in_array((string) ($task['mode'] ?? 'single'), ['single', 'multi'], true) ? (string) ($task['mode'] ?? 'single') : 'single',
'default_enabled' => array_key_exists('default_enabled', $task) ? (bool) $task['default_enabled'] : false,
'default_cron' => trim((string) ($task['default_cron'] ?? '0 * * * *')),
'default_timezone' => trim((string) ($task['default_timezone'] ?? 'UTC')) ?: 'UTC',
@@ -61,8 +62,9 @@ final class ModuleCronScheduler
$statuses = [];
foreach ($definitions as $name => $definition) {
$state = $states[$name] ?? [];
$config = $this->jobConfig($definition, $settings);
foreach ($this->jobConfigs($definition, $settings) as $entryIndex => $config) {
$stateKey = $this->stateKey($name, $entryIndex);
$state = $states[$stateKey] ?? [];
$timezone = $this->safeTimezone((string) ($config['timezone'] ?? 'UTC'));
$expression = trim((string) ($config['cron_expression'] ?? ''));
$isLocked = $this->isLockActive($state, $nowUtc->getTimestamp());
@@ -87,6 +89,9 @@ final class ModuleCronScheduler
}
$statuses[] = $definition + [
'job_name' => $name,
'entry_index' => $entryIndex,
'state_key' => $stateKey,
'config' => $config,
'state' => $state,
'enabled' => !empty($config['enabled']),
@@ -101,6 +106,7 @@ final class ModuleCronScheduler
'next_due_at_local' => $nextDueUtc?->setTimezone($timezone)->format('Y-m-d H:i:s'),
];
}
}
return $statuses;
}
@@ -115,14 +121,15 @@ final class ModuleCronScheduler
if (!$this->modules->hasFunction($moduleName, (string) $task['callback'])) {
$results[] = [
'task' => $task['name'],
'task' => $task['job_name'],
'entry_index' => $task['entry_index'],
'ok' => false,
'message' => 'Callback nicht registriert.',
];
continue;
}
if (!$this->acquireLock($moduleName, (string) $task['name'], (int) $task['lock_minutes'])) {
if (!$this->acquireLock($moduleName, (string) $task['state_key'], (int) $task['lock_minutes'])) {
continue;
}
@@ -130,7 +137,7 @@ final class ModuleCronScheduler
$scheduledForUtc = trim((string) ($task['previous_due_at'] ?? ''));
$timezone = $this->safeTimezone((string) ($task['timezone'] ?? 'UTC'));
$this->persistState($moduleName, (string) $task['name'], [
$this->persistState($moduleName, (string) $task['state_key'], [
'last_started_at' => $startedAt,
'last_status' => 'running',
'last_message' => 'Cron-Lauf gestartet.',
@@ -163,16 +170,17 @@ final class ModuleCronScheduler
if ($ok && !$skipped) {
$payload['last_success_at'] = $finishedAt;
}
$this->persistState($moduleName, (string) $task['name'], $payload);
$this->persistState($moduleName, (string) $task['state_key'], $payload);
$results[] = [
'task' => $task['name'],
'task' => $task['job_name'],
'entry_index' => $task['entry_index'],
'ok' => $ok,
'message' => $message,
];
} catch (\Throwable $e) {
$finishedAt = gmdate('Y-m-d H:i:s');
$this->persistState($moduleName, (string) $task['name'], [
$this->persistState($moduleName, (string) $task['state_key'], [
'last_finished_at' => $finishedAt,
'last_status' => 'error',
'last_message' => $e->getMessage(),
@@ -180,7 +188,8 @@ final class ModuleCronScheduler
'last_scheduled_for' => $scheduledForUtc !== '' ? $scheduledForUtc : null,
]);
$results[] = [
'task' => $task['name'],
'task' => $task['job_name'],
'entry_index' => $task['entry_index'],
'ok' => false,
'message' => $e->getMessage(),
];
@@ -190,19 +199,64 @@ final class ModuleCronScheduler
return $results;
}
private function jobConfig(array $definition, array $settings): array
private function jobConfigs(array $definition, array $settings): array
{
$jobs = is_array($settings['cron_jobs'] ?? null) ? $settings['cron_jobs'] : [];
$job = is_array($jobs[$definition['name']] ?? null) ? $jobs[$definition['name']] : [];
$jobs = is_array($settings['scheduler_jobs'] ?? null)
? $settings['scheduler_jobs']
: (is_array($settings['cron_jobs'] ?? null) ? $settings['cron_jobs'] : []);
$jobExists = array_key_exists($definition['name'], $jobs) && is_array($jobs[$definition['name']] ?? null);
$job = $jobExists ? $jobs[$definition['name']] : [];
$timezoneSetting = trim((string) ($definition['timezone_setting'] ?? ''));
$fallbackTimezone = $timezoneSetting !== '' ? trim((string) ($settings[$timezoneSetting] ?? '')) : '';
return [
'enabled' => array_key_exists('enabled', $job) ? $this->settingBool($job['enabled'], (bool) $definition['default_enabled']) : (bool) $definition['default_enabled'],
'cron_expression' => trim((string) ($job['cron_expression'] ?? $definition['default_cron'] ?? '')),
'timezone' => trim((string) ($job['timezone'] ?? ($fallbackTimezone !== '' ? $fallbackTimezone : $definition['default_timezone']))),
'builder' => is_array($job['builder'] ?? null) ? $job['builder'] : [],
$defaultEntry = [
'enabled' => (bool) $definition['default_enabled'],
'cron_expression' => trim((string) ($definition['default_cron'] ?? '0 * * * *')),
'timezone' => trim((string) ($fallbackTimezone !== '' ? $fallbackTimezone : ($definition['default_timezone'] ?? 'UTC'))),
'builder' => [],
];
$entries = is_array($job['entries'] ?? null) ? $job['entries'] : [];
if (!$jobExists && $entries === []) {
$legacyEntry = [];
foreach (['enabled', 'cron_expression', 'timezone', 'builder'] as $field) {
if (array_key_exists($field, $job)) {
$legacyEntry[$field] = $job[$field];
}
}
$entries = [$legacyEntry !== [] ? $legacyEntry : $defaultEntry];
}
if ($jobExists && $entries === [] && ($definition['mode'] ?? 'single') === 'multi') {
return [];
}
$result = [];
foreach (array_values($entries) as $entry) {
if (!is_array($entry)) {
continue;
}
$result[] = [
'enabled' => array_key_exists('enabled', $entry) ? $this->settingBool($entry['enabled'], (bool) $defaultEntry['enabled']) : (bool) $defaultEntry['enabled'],
'cron_expression' => trim((string) ($entry['cron_expression'] ?? $defaultEntry['cron_expression'])),
'timezone' => trim((string) ($entry['timezone'] ?? $defaultEntry['timezone'])),
'builder' => is_array($entry['builder'] ?? null) ? $entry['builder'] : [],
];
}
if ($result === []) {
$result[] = $defaultEntry;
}
if (($definition['mode'] ?? 'single') !== 'multi') {
return [0 => $result[0]];
}
return $result;
}
private function stateKey(string $jobName, int $entryIndex): string
{
return $jobName . '#' . $entryIndex;
}
private function fetchStates(string $moduleName): array

View File

@@ -307,6 +307,7 @@ final class ModuleManager
'description' => $data['description'] ?? '',
'setup' => $data['setup'] ?? [],
'interval_tasks' => $data['interval_tasks'] ?? [],
'scheduler_jobs' => $data['scheduler_jobs'] ?? ($data['cron_tasks'] ?? []),
'cron_tasks' => $data['cron_tasks'] ?? [],
'menu' => $data['menu'] ?? [],
'sidebar' => $data['sidebar'] ?? [],

View File

@@ -4,7 +4,27 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/config/fileload.php';
$targetModule = trim((string) ($argv[1] ?? ''));
$targetModule = '';
$statusOnly = false;
foreach (array_slice($argv, 1) as $arg) {
$arg = trim((string) $arg);
if ($arg === '') {
continue;
}
if ($arg === '--status') {
$statusOnly = true;
continue;
}
if (str_starts_with($arg, '--module=')) {
$targetModule = trim(substr($arg, 9));
continue;
}
if ($targetModule === '') {
$targetModule = $arg;
}
}
$modules = app()->modules()->all();
$results = [];
@@ -19,8 +39,17 @@ foreach ($modules as $name => $meta) {
continue;
}
$taskDefs = app()->modules()->intervalTasks($name);
if ($taskDefs === []) {
$intervalDefs = app()->modules()->intervalTasks($name);
$cronDefs = app()->modules()->cronTasks($name);
if ($intervalDefs === [] && $cronDefs === []) {
continue;
}
if ($statusOnly) {
$results[$name] = [
'interval' => app()->modules()->intervalTaskStatuses($name),
'cron' => app()->modules()->cronTaskStatuses($name),
];
continue;
}
@@ -34,5 +63,6 @@ echo json_encode([
'ok' => true,
'ran_at_utc' => gmdate('Y-m-d H:i:s'),
'module' => $targetModule !== '' ? $targetModule : null,
'mode' => $statusOnly ? 'status' : 'run',
'results' => $results,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;