asdasd
This commit is contained in:
@@ -68,8 +68,6 @@ $mm->registerFunction($moduleName, 'settings', static function (): array {
|
|||||||
'preferred_currencies' => $preferredCurrencies,
|
'preferred_currencies' => $preferredCurrencies,
|
||||||
'currency_catalog' => $currencyCatalog,
|
'currency_catalog' => $currencyCatalog,
|
||||||
'currency_catalog_synced_at' => trim((string) ($saved['currency_catalog_synced_at'] ?? '')),
|
'currency_catalog_synced_at' => trim((string) ($saved['currency_catalog_synced_at'] ?? '')),
|
||||||
'daily_refresh_enabled' => array_key_exists('daily_refresh_enabled', $saved) ? (bool) $saved['daily_refresh_enabled'] : true,
|
|
||||||
'daily_refresh_hour' => max(0, min(23, (int) ($saved['daily_refresh_hour'] ?? 18))),
|
|
||||||
'schedule_timezone' => trim((string) ($saved['schedule_timezone'] ?? 'Europe/Berlin')) ?: 'Europe/Berlin',
|
'schedule_timezone' => trim((string) ($saved['schedule_timezone'] ?? 'Europe/Berlin')) ?: 'Europe/Berlin',
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,20 +22,24 @@
|
|||||||
{ "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." },
|
{ "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." },
|
||||||
{ "name": "display_base_currency", "label": "Anzeige-Basiswaehrung", "type": "select", "required": false, "help": "Basis fuer die Anzeige der zuletzt gespeicherten Kurse im Modul." },
|
{ "name": "display_base_currency", "label": "Anzeige-Basiswaehrung", "type": "select", "required": false, "help": "Basis fuer die Anzeige der zuletzt gespeicherten Kurse im Modul." },
|
||||||
{ "name": "preferred_currencies", "label": "Bevorzugte Waehrungen", "type": "multiselect", "required": false, "help": "Auswahl aus dem synchronisierten Waehrungskatalog." },
|
{ "name": "preferred_currencies", "label": "Bevorzugte Waehrungen", "type": "multiselect", "required": false, "help": "Auswahl aus dem synchronisierten Waehrungskatalog." },
|
||||||
{ "name": "daily_refresh_enabled", "label": "Taeglichen Abruf aktivieren", "type": "checkbox", "required": false },
|
|
||||||
{ "name": "daily_refresh_hour", "label": "Taegliche Abrufstunde", "type": "number", "required": false, "help": "Lokale Stunde fuer den Scheduler, standardmaessig 18." },
|
|
||||||
{ "name": "schedule_timezone", "label": "Scheduler-Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" }
|
{ "name": "schedule_timezone", "label": "Scheduler-Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"interval_tasks": [
|
"cron_tasks": [
|
||||||
{
|
{
|
||||||
"name": "daily_refresh",
|
"name": "rates_refresh",
|
||||||
"label": "Taeglicher FX-Abruf",
|
"label": "Kursabruf",
|
||||||
"callback": "scheduled_refresh",
|
"callback": "scheduled_refresh",
|
||||||
"enabled_setting": "daily_refresh_enabled",
|
|
||||||
"default_enabled": true,
|
"default_enabled": true,
|
||||||
"default_interval_hours": 24,
|
"default_cron": "0 18 * * *",
|
||||||
"lock_minutes": 120
|
"default_timezone": "Europe/Berlin",
|
||||||
|
"timezone_setting": "schedule_timezone",
|
||||||
|
"lock_minutes": 120,
|
||||||
|
"help": "Zeitgesteuerter Abruf und das Speichern neuer FX-Snapshots.",
|
||||||
|
"builder": {
|
||||||
|
"allow_manual": true,
|
||||||
|
"presets": ["daily", "every_x_days", "weekly", "monthly_day", "every_x_hours"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"db_defaults": {
|
"db_defaults": {
|
||||||
|
|||||||
@@ -302,42 +302,10 @@ final class FxRatesService
|
|||||||
|
|
||||||
public function runScheduledRefresh(array $context = []): array
|
public function runScheduledRefresh(array $context = []): array
|
||||||
{
|
{
|
||||||
if (!$this->dailyRefreshEnabled()) {
|
|
||||||
return ['ok' => true, 'message' => 'Taeglicher FX-Abruf ist deaktiviert.', 'skipped' => true];
|
|
||||||
}
|
|
||||||
|
|
||||||
$timezone = $this->scheduleTimezone();
|
|
||||||
$nowLocal = new DateTimeImmutable('now', $timezone);
|
|
||||||
$targetHour = $this->dailyRefreshHour();
|
|
||||||
|
|
||||||
if ((int) $nowLocal->format('G') !== $targetHour) {
|
|
||||||
return [
|
|
||||||
'ok' => true,
|
|
||||||
'message' => 'Kein FX-Abruf: aktuelles Zeitfenster ist ' . $nowLocal->format('H:i') . ', Ziel ist ' . str_pad((string) $targetHour, 2, '0', STR_PAD_LEFT) . ':00.',
|
|
||||||
'skipped' => true,
|
|
||||||
'context' => $context,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$latest = $this->repository->getLatestFetch($this->defaultBaseCurrency());
|
|
||||||
if (is_array($latest) && trim((string) ($latest['fetched_at'] ?? '')) !== '') {
|
|
||||||
$latestLocal = new DateTimeImmutable((string) $latest['fetched_at'], new DateTimeZone('UTC'));
|
|
||||||
$latestLocal = $latestLocal->setTimezone($timezone);
|
|
||||||
if ($latestLocal->format('Y-m-d') === $nowLocal->format('Y-m-d') && (int) $latestLocal->format('G') >= $targetHour) {
|
|
||||||
return [
|
|
||||||
'ok' => true,
|
|
||||||
'message' => 'Kein FX-Abruf: fuer heute existiert bereits ein Snapshot nach ' . str_pad((string) $targetHour, 2, '0', STR_PAD_LEFT) . ':00.',
|
|
||||||
'skipped' => true,
|
|
||||||
'fetch' => $this->localizeFetch($latest),
|
|
||||||
'context' => $context,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $this->refreshLatestRates(null, $this->defaultBaseCurrency());
|
$result = $this->refreshLatestRates(null, $this->defaultBaseCurrency());
|
||||||
return [
|
return [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'message' => 'Taeglicher FX-Abruf ausgefuehrt: ' . (int) ($result['updated_count'] ?? 0) . ' Kurse gespeichert.',
|
'message' => 'Geplanter FX-Abruf ausgefuehrt: ' . (int) ($result['updated_count'] ?? 0) . ' Kurse gespeichert.',
|
||||||
'result' => $result,
|
'result' => $result,
|
||||||
'context' => $context,
|
'context' => $context,
|
||||||
];
|
];
|
||||||
@@ -980,16 +948,6 @@ final class FxRatesService
|
|||||||
return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR';
|
return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function dailyRefreshEnabled(): bool
|
|
||||||
{
|
|
||||||
return !empty($this->settings['daily_refresh_enabled']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function dailyRefreshHour(): int
|
|
||||||
{
|
|
||||||
return max(0, min(23, (int) ($this->settings['daily_refresh_hour'] ?? 18)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function scheduleTimezone(): DateTimeZone
|
private function scheduleTimezone(): DateTimeZone
|
||||||
{
|
{
|
||||||
$timezone = trim((string) ($this->settings['schedule_timezone'] ?? 'Europe/Berlin'));
|
$timezone = trim((string) ($this->settings['schedule_timezone'] ?? 'Europe/Berlin'));
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ foreach ($fields as $field) {
|
|||||||
$isFxRatesSetup = $moduleName === 'fx-rates';
|
$isFxRatesSetup = $moduleName === 'fx-rates';
|
||||||
$current = modules()->settings($moduleName);
|
$current = modules()->settings($moduleName);
|
||||||
$intervalTaskStatuses = modules()->intervalTaskStatuses($moduleName);
|
$intervalTaskStatuses = modules()->intervalTaskStatuses($moduleName);
|
||||||
|
$cronTaskDefinitions = modules()->cronTasks($moduleName);
|
||||||
|
$cronTaskStatuses = modules()->cronTaskStatuses($moduleName);
|
||||||
$setupActions = modules()->hasFunction($moduleName, 'setup_actions')
|
$setupActions = modules()->hasFunction($moduleName, 'setup_actions')
|
||||||
? (array) module_fn($moduleName, 'setup_actions')
|
? (array) module_fn($moduleName, 'setup_actions')
|
||||||
: [];
|
: [];
|
||||||
@@ -285,6 +287,16 @@ $formatRunTimestamp = static function (?string $value): string {
|
|||||||
return date('Y-m-d H:i:s', $ts);
|
return date('Y-m-d H:i:s', $ts);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$cronWeekdays = [
|
||||||
|
'0' => 'Sonntag',
|
||||||
|
'1' => 'Montag',
|
||||||
|
'2' => 'Dienstag',
|
||||||
|
'3' => 'Mittwoch',
|
||||||
|
'4' => 'Donnerstag',
|
||||||
|
'5' => 'Freitag',
|
||||||
|
'6' => 'Samstag',
|
||||||
|
];
|
||||||
|
|
||||||
$renderField = function (array $field) use (&$current, $getNested, $driverOptions): void {
|
$renderField = function (array $field) use (&$current, $getNested, $driverOptions): void {
|
||||||
$name = (string)($field['name'] ?? '');
|
$name = (string)($field['name'] ?? '');
|
||||||
if ($name === '') {
|
if ($name === '') {
|
||||||
@@ -371,6 +383,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
$current = array_replace_recursive($current, $payload);
|
$current = array_replace_recursive($current, $payload);
|
||||||
|
|
||||||
|
if ($cronTaskDefinitions !== []) {
|
||||||
|
$cronJobs = is_array($current['cron_jobs'] ?? null) ? $current['cron_jobs'] : [];
|
||||||
|
foreach ($cronTaskDefinitions as $cronTask) {
|
||||||
|
if (!is_array($cronTask)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cronName = trim((string) ($cronTask['name'] ?? ''));
|
||||||
|
if ($cronName === '') {
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$current['cron_jobs'] = $cronJobs;
|
||||||
|
}
|
||||||
|
|
||||||
$postedTestGroup = (string)($_POST['test_db'] ?? '');
|
$postedTestGroup = (string)($_POST['test_db'] ?? '');
|
||||||
$postedResetGroup = (string)($_POST['reset_db'] ?? '');
|
$postedResetGroup = (string)($_POST['reset_db'] ?? '');
|
||||||
$postedSetupAction = trim((string)($_POST['module_setup_action'] ?? ''));
|
$postedSetupAction = trim((string)($_POST['module_setup_action'] ?? ''));
|
||||||
@@ -611,21 +656,14 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
|
|||||||
<div class="setup-panel__head">
|
<div class="setup-panel__head">
|
||||||
<div>
|
<div>
|
||||||
<span class="pill">Scheduler</span>
|
<span class="pill">Scheduler</span>
|
||||||
<h2>Taeglicher Abruf</h2>
|
<h2>Cron-Zeitzone</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setup-grid">
|
<div class="setup-grid">
|
||||||
<label class="setup-field muted">
|
|
||||||
<span>Taeglichen Abruf aktivieren</span>
|
|
||||||
<input type="checkbox" name="daily_refresh_enabled" value="1" <?= !empty($current['daily_refresh_enabled']) ? 'checked' : '' ?>>
|
|
||||||
</label>
|
|
||||||
<label class="setup-field muted">
|
|
||||||
<span>Taegliche Abrufstunde</span>
|
|
||||||
<input type="number" name="daily_refresh_hour" min="0" max="23" value="<?= e((string) ($current['daily_refresh_hour'] ?? 18)) ?>">
|
|
||||||
</label>
|
|
||||||
<label class="setup-field muted">
|
<label class="setup-field muted">
|
||||||
<span>Scheduler-Zeitzone</span>
|
<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')) ?>">
|
||||||
|
<small class="muted">Diese Zeitzone wird fuer Cron-Jobs und die lokale Zeitberechnung verwendet.</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -736,6 +774,68 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
|
|||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($cronTaskStatuses !== []): ?>
|
||||||
|
<section class="setup-panel">
|
||||||
|
<div class="setup-panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="pill">Automationen</span>
|
||||||
|
<h2>Cron-Jobs</h2>
|
||||||
|
<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
|
||||||
|
$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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<?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>
|
||||||
|
</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">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>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="setup-actions">
|
<div class="setup-actions">
|
||||||
<button class="cta-button" type="submit">Speichern</button>
|
<button class="cta-button" type="submit">Speichern</button>
|
||||||
<a class="nav-link" href="/modules/access/<?= e($moduleName) ?>">Zugriff verwalten</a>
|
<a class="nav-link" href="/modules/access/<?= e($moduleName) ?>">Zugriff verwalten</a>
|
||||||
@@ -1034,6 +1134,68 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
|
|||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($cronTaskStatuses !== []): ?>
|
||||||
|
<section class="setup-panel">
|
||||||
|
<div class="setup-panel__head">
|
||||||
|
<div>
|
||||||
|
<span class="pill">Automationen</span>
|
||||||
|
<h2>Cron-Jobs</h2>
|
||||||
|
<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
|
||||||
|
$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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<?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>
|
||||||
|
</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">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>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($setupActions !== []): ?>
|
<?php if ($setupActions !== []): ?>
|
||||||
<section class="setup-panel">
|
<section class="setup-panel">
|
||||||
<div class="setup-panel__head">
|
<div class="setup-panel__head">
|
||||||
@@ -1172,5 +1334,79 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
|
|||||||
<a class="nav-link" href="/modules">Zurück</a>
|
<a class="nav-link" href="/modules">Zurück</a>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const jobs = document.querySelectorAll('[data-cron-job]');
|
||||||
|
if (!jobs.length) return;
|
||||||
|
|
||||||
|
const buildExpression = (kind, values) => {
|
||||||
|
const [hourRaw, minuteRaw] = String(values.time || '18:00').split(':');
|
||||||
|
const minute = Math.max(0, Math.min(59, Number(minuteRaw || 0)));
|
||||||
|
const hour = Math.max(0, Math.min(23, Number(hourRaw || 0)));
|
||||||
|
const everyDays = Math.max(1, Number(values.intervalDays || 2));
|
||||||
|
const weekday = String(values.weekday || '1');
|
||||||
|
const monthDay = Math.max(1, Math.min(31, Number(values.monthDay || 1)));
|
||||||
|
const everyHours = Math.max(1, Math.min(23, Number(values.intervalHours || 6)));
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case 'every_x_days':
|
||||||
|
return `${minute} ${hour} */${everyDays} * *`;
|
||||||
|
case 'weekly':
|
||||||
|
return `${minute} ${hour} * * ${weekday}`;
|
||||||
|
case 'monthly_day':
|
||||||
|
return `${minute} ${hour} ${monthDay} * *`;
|
||||||
|
case 'every_x_hours':
|
||||||
|
return `${minute} */${everyHours} * * *`;
|
||||||
|
case 'daily':
|
||||||
|
default:
|
||||||
|
return `${minute} ${hour} * * *`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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]');
|
||||||
|
|
||||||
|
if (!expression || !mode || !fields || !kind || !time || !intervalDays || !weekday || !monthDay || !intervalHours) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
const builderMode = mode.value === 'manual' ? 'manual' : 'builder';
|
||||||
|
fields.hidden = builderMode === 'manual';
|
||||||
|
expression.readOnly = builderMode !== 'manual';
|
||||||
|
if (builderMode === 'builder') {
|
||||||
|
expression.value = buildExpression(kind.value, {
|
||||||
|
time: time.value,
|
||||||
|
intervalDays: intervalDays.value,
|
||||||
|
weekday: weekday.value,
|
||||||
|
monthDay: monthDay.value,
|
||||||
|
intervalHours: intervalHours.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
[mode, kind, time, intervalDays, weekday, monthDay, intervalHours].forEach((node) => {
|
||||||
|
node.addEventListener('change', sync);
|
||||||
|
node.addEventListener('input', sync);
|
||||||
|
});
|
||||||
|
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
[data-cron-builder-fields] {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ if (preg_match('~^api/fx-rates(?:/(.*))?$~', $uriPath, $apiMatches)) {
|
|||||||
|
|
||||||
require_once $projectRoot . '/modules/fx-rates/bootstrap.php';
|
require_once $projectRoot . '/modules/fx-rates/bootstrap.php';
|
||||||
app()->modules()->runDueIntervalTasks('fx-rates');
|
app()->modules()->runDueIntervalTasks('fx-rates');
|
||||||
|
app()->modules()->runDueCronTasks('fx-rates');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$service = module_fn('fx-rates', 'service');
|
$service = module_fn('fx-rates', 'service');
|
||||||
@@ -261,6 +262,7 @@ if (str_starts_with($uriPath, 'modules/install')) {
|
|||||||
}
|
}
|
||||||
if ($modulePage) {
|
if ($modulePage) {
|
||||||
app()->modules()->runDueIntervalTasks($module);
|
app()->modules()->runDueIntervalTasks($module);
|
||||||
|
app()->modules()->runDueCronTasks($module);
|
||||||
$target = $modulePage;
|
$target = $modulePage;
|
||||||
} else {
|
} else {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
|
|||||||
@@ -76,6 +76,22 @@ final class BaseSchema
|
|||||||
)"
|
)"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS nexus_module_cron_runs (
|
||||||
|
module_name TEXT NOT NULL,
|
||||||
|
job_name TEXT NOT NULL,
|
||||||
|
last_scheduled_for TIMESTAMPTZ NULL,
|
||||||
|
last_started_at TIMESTAMPTZ NULL,
|
||||||
|
last_finished_at TIMESTAMPTZ NULL,
|
||||||
|
last_success_at TIMESTAMPTZ NULL,
|
||||||
|
last_status TEXT NULL,
|
||||||
|
last_message TEXT NULL,
|
||||||
|
lock_until TIMESTAMPTZ NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (module_name, job_name)
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
|
||||||
$pdo->exec(
|
$pdo->exec(
|
||||||
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
|
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
|
||||||
sub TEXT PRIMARY KEY,
|
sub TEXT PRIMARY KEY,
|
||||||
@@ -179,6 +195,22 @@ final class BaseSchema
|
|||||||
)"
|
)"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS nexus_module_cron_runs (
|
||||||
|
module_name TEXT NOT NULL,
|
||||||
|
job_name TEXT NOT NULL,
|
||||||
|
last_scheduled_for TEXT NULL,
|
||||||
|
last_started_at TEXT NULL,
|
||||||
|
last_finished_at TEXT NULL,
|
||||||
|
last_success_at TEXT NULL,
|
||||||
|
last_status TEXT NULL,
|
||||||
|
last_message TEXT NULL,
|
||||||
|
lock_until TEXT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (module_name, job_name)
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
|
||||||
$pdo->exec(
|
$pdo->exec(
|
||||||
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
|
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
|
||||||
sub TEXT PRIMARY KEY,
|
sub TEXT PRIMARY KEY,
|
||||||
@@ -282,6 +314,22 @@ final class BaseSchema
|
|||||||
)"
|
)"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS nexus_module_cron_runs (
|
||||||
|
module_name VARCHAR(190) NOT NULL,
|
||||||
|
job_name VARCHAR(190) NOT NULL,
|
||||||
|
last_scheduled_for DATETIME NULL,
|
||||||
|
last_started_at DATETIME NULL,
|
||||||
|
last_finished_at DATETIME NULL,
|
||||||
|
last_success_at DATETIME NULL,
|
||||||
|
last_status VARCHAR(32) NULL,
|
||||||
|
last_message TEXT NULL,
|
||||||
|
lock_until DATETIME NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (module_name, job_name)
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
|
||||||
$pdo->exec(
|
$pdo->exec(
|
||||||
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
|
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
|
||||||
sub VARCHAR(190) PRIMARY KEY,
|
sub VARCHAR(190) PRIMARY KEY,
|
||||||
|
|||||||
214
src/App/CronExpression.php
Normal file
214
src/App/CronExpression.php
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use DateInterval;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
|
||||||
|
final class CronExpression
|
||||||
|
{
|
||||||
|
/** @var array<int, true> */
|
||||||
|
private array $minutes;
|
||||||
|
/** @var array<int, true> */
|
||||||
|
private array $hours;
|
||||||
|
/** @var array<int, true> */
|
||||||
|
private array $daysOfMonth;
|
||||||
|
/** @var array<int, true> */
|
||||||
|
private array $months;
|
||||||
|
/** @var array<int, true> */
|
||||||
|
private array $daysOfWeek;
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
private string $expression,
|
||||||
|
array $minutes,
|
||||||
|
array $hours,
|
||||||
|
array $daysOfMonth,
|
||||||
|
array $months,
|
||||||
|
array $daysOfWeek,
|
||||||
|
private bool $daysOfMonthWildcard,
|
||||||
|
private bool $daysOfWeekWildcard
|
||||||
|
) {
|
||||||
|
$this->minutes = $minutes;
|
||||||
|
$this->hours = $hours;
|
||||||
|
$this->daysOfMonth = $daysOfMonth;
|
||||||
|
$this->months = $months;
|
||||||
|
$this->daysOfWeek = $daysOfWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function parse(string $expression): self
|
||||||
|
{
|
||||||
|
$normalized = preg_replace('/\s+/', ' ', trim($expression)) ?? '';
|
||||||
|
if ($normalized === '') {
|
||||||
|
throw new \InvalidArgumentException('Cron-Ausdruck fehlt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode(' ', $normalized);
|
||||||
|
if (count($parts) !== 5) {
|
||||||
|
throw new \InvalidArgumentException('Cron-Ausdruck muss aus 5 Feldern bestehen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
$normalized,
|
||||||
|
self::parseField($parts[0], 0, 59),
|
||||||
|
self::parseField($parts[1], 0, 23),
|
||||||
|
self::parseField($parts[2], 1, 31),
|
||||||
|
self::parseField($parts[3], 1, 12, [
|
||||||
|
'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4,
|
||||||
|
'MAY' => 5, 'JUN' => 6, 'JUL' => 7, 'AUG' => 8,
|
||||||
|
'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12,
|
||||||
|
]),
|
||||||
|
self::parseField($parts[4], 0, 6, [
|
||||||
|
'SUN' => 0, 'MON' => 1, 'TUE' => 2, 'WED' => 3,
|
||||||
|
'THU' => 4, 'FRI' => 5, 'SAT' => 6,
|
||||||
|
'7' => 0,
|
||||||
|
]),
|
||||||
|
trim($parts[2]) === '*',
|
||||||
|
trim($parts[4]) === '*'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expression(): string
|
||||||
|
{
|
||||||
|
return $this->expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function matches(DateTimeImmutable $utcDateTime, DateTimeZone $timezone): bool
|
||||||
|
{
|
||||||
|
$local = $utcDateTime->setTimezone($timezone);
|
||||||
|
$minute = (int) $local->format('i');
|
||||||
|
$hour = (int) $local->format('G');
|
||||||
|
$dayOfMonth = (int) $local->format('j');
|
||||||
|
$month = (int) $local->format('n');
|
||||||
|
$dayOfWeek = (int) $local->format('w');
|
||||||
|
|
||||||
|
if (!isset($this->minutes[$minute]) || !isset($this->hours[$hour]) || !isset($this->months[$month])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dayOfMonthMatch = isset($this->daysOfMonth[$dayOfMonth]);
|
||||||
|
$dayOfWeekMatch = isset($this->daysOfWeek[$dayOfWeek]);
|
||||||
|
if ($this->daysOfMonthWildcard && $this->daysOfWeekWildcard) {
|
||||||
|
$dayMatches = true;
|
||||||
|
} elseif ($this->daysOfMonthWildcard) {
|
||||||
|
$dayMatches = $dayOfWeekMatch;
|
||||||
|
} elseif ($this->daysOfWeekWildcard) {
|
||||||
|
$dayMatches = $dayOfMonthMatch;
|
||||||
|
} else {
|
||||||
|
$dayMatches = $dayOfMonthMatch || $dayOfWeekMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dayMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previousRun(DateTimeImmutable $utcDateTime, DateTimeZone $timezone, int $lookbackMinutes = 527040): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
$cursor = $this->floorToMinute($utcDateTime);
|
||||||
|
for ($i = 0; $i <= $lookbackMinutes; $i++) {
|
||||||
|
if ($this->matches($cursor, $timezone)) {
|
||||||
|
return $cursor;
|
||||||
|
}
|
||||||
|
$cursor = $cursor->sub(new DateInterval('PT1M'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextRun(DateTimeImmutable $utcDateTime, DateTimeZone $timezone, int $lookaheadMinutes = 527040): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
$cursor = $this->floorToMinute($utcDateTime)->add(new DateInterval('PT1M'));
|
||||||
|
for ($i = 0; $i <= $lookaheadMinutes; $i++) {
|
||||||
|
if ($this->matches($cursor, $timezone)) {
|
||||||
|
return $cursor;
|
||||||
|
}
|
||||||
|
$cursor = $cursor->add(new DateInterval('PT1M'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, true> */
|
||||||
|
private static function parseField(string $field, int $min, int $max, array $aliases = []): array
|
||||||
|
{
|
||||||
|
$field = strtoupper(trim($field));
|
||||||
|
if ($field === '*') {
|
||||||
|
$all = [];
|
||||||
|
for ($value = $min; $value <= $max; $value++) {
|
||||||
|
$all[$value] = true;
|
||||||
|
}
|
||||||
|
return $all;
|
||||||
|
}
|
||||||
|
|
||||||
|
$values = [];
|
||||||
|
foreach (explode(',', $field) as $segment) {
|
||||||
|
$segment = strtoupper(trim($segment));
|
||||||
|
if ($segment === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$step = 1;
|
||||||
|
if (str_contains($segment, '/')) {
|
||||||
|
[$segment, $stepPart] = explode('/', $segment, 2);
|
||||||
|
if (!is_numeric($stepPart) || (int) $stepPart <= 0) {
|
||||||
|
throw new \InvalidArgumentException('Ungueltiger Cron-Step in Feld "' . $field . '".');
|
||||||
|
}
|
||||||
|
$step = (int) $stepPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($segment === '*') {
|
||||||
|
$start = $min;
|
||||||
|
$end = $max;
|
||||||
|
} elseif (str_contains($segment, '-')) {
|
||||||
|
[$startPart, $endPart] = explode('-', $segment, 2);
|
||||||
|
$start = self::normalizePart($startPart, $aliases);
|
||||||
|
$end = self::normalizePart($endPart, $aliases);
|
||||||
|
} else {
|
||||||
|
$start = self::normalizePart($segment, $aliases);
|
||||||
|
$end = $start;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($start < $min || $start > $max || $end < $min || $end > $max || $end < $start) {
|
||||||
|
throw new \InvalidArgumentException('Cron-Feld "' . $field . '" liegt ausserhalb des erlaubten Bereichs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($value = $start; $value <= $end; $value += $step) {
|
||||||
|
$values[$value] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($values === []) {
|
||||||
|
throw new \InvalidArgumentException('Cron-Feld "' . $field . '" ist leer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($values);
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizePart(string $part, array $aliases): int
|
||||||
|
{
|
||||||
|
$part = strtoupper(trim($part));
|
||||||
|
if ($part === '') {
|
||||||
|
throw new \InvalidArgumentException('Leerer Cron-Wert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists($part, $aliases)) {
|
||||||
|
return (int) $aliases[$part];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_numeric($part)) {
|
||||||
|
throw new \InvalidArgumentException('Ungueltiger Cron-Wert "' . $part . '".');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $part;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function floorToMinute(DateTimeImmutable $utcDateTime): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $utcDateTime->setTime(
|
||||||
|
(int) $utcDateTime->format('H'),
|
||||||
|
(int) $utcDateTime->format('i'),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
371
src/App/ModuleCronScheduler.php
Normal file
371
src/App/ModuleCronScheduler.php
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
|
||||||
|
final class ModuleCronScheduler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ModuleManager $modules,
|
||||||
|
private ?\PDO $pdo
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function definitions(string $moduleName): array
|
||||||
|
{
|
||||||
|
$module = $this->modules->get($moduleName);
|
||||||
|
$tasks = is_array($module['cron_tasks'] ?? null) ? $module['cron_tasks'] : [];
|
||||||
|
$definitions = [];
|
||||||
|
|
||||||
|
foreach ($tasks as $task) {
|
||||||
|
if (!is_array($task)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim((string) ($task['name'] ?? ''));
|
||||||
|
$callback = trim((string) ($task['callback'] ?? ''));
|
||||||
|
if ($name === '' || $callback === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$definitions[$name] = [
|
||||||
|
'name' => $name,
|
||||||
|
'label' => trim((string) ($task['label'] ?? $name)),
|
||||||
|
'callback' => $callback,
|
||||||
|
'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',
|
||||||
|
'timezone_setting' => trim((string) ($task['timezone_setting'] ?? '')),
|
||||||
|
'lock_minutes' => max(1, (int) ($task['lock_minutes'] ?? 10)),
|
||||||
|
'builder' => is_array($task['builder'] ?? null) ? $task['builder'] : [],
|
||||||
|
'help' => trim((string) ($task['help'] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $definitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function statuses(string $moduleName): array
|
||||||
|
{
|
||||||
|
$definitions = $this->definitions($moduleName);
|
||||||
|
if ($definitions === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = $this->modules->settings($moduleName);
|
||||||
|
$states = $this->fetchStates($moduleName);
|
||||||
|
$nowUtc = new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||||
|
$statuses = [];
|
||||||
|
|
||||||
|
foreach ($definitions as $name => $definition) {
|
||||||
|
$state = $states[$name] ?? [];
|
||||||
|
$config = $this->jobConfig($definition, $settings);
|
||||||
|
$timezone = $this->safeTimezone((string) ($config['timezone'] ?? 'UTC'));
|
||||||
|
$expression = trim((string) ($config['cron_expression'] ?? ''));
|
||||||
|
$isLocked = $this->isLockActive($state, $nowUtc->getTimestamp());
|
||||||
|
$parseError = null;
|
||||||
|
$previousDueUtc = null;
|
||||||
|
$nextDueUtc = null;
|
||||||
|
$isDue = false;
|
||||||
|
|
||||||
|
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);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$parseError = $exception->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
$statuses[] = $definition + [
|
||||||
|
'config' => $config,
|
||||||
|
'state' => $state,
|
||||||
|
'enabled' => !empty($config['enabled']),
|
||||||
|
'cron_expression' => $expression,
|
||||||
|
'timezone' => $timezone->getName(),
|
||||||
|
'is_due' => $isDue,
|
||||||
|
'is_locked' => $isLocked,
|
||||||
|
'parse_error' => $parseError,
|
||||||
|
'previous_due_at' => $previousDueUtc?->format('Y-m-d H:i:s'),
|
||||||
|
'next_due_at' => $nextDueUtc?->format('Y-m-d H:i:s'),
|
||||||
|
'previous_due_at_local' => $previousDueUtc?->setTimezone($timezone)->format('Y-m-d H:i:s'),
|
||||||
|
'next_due_at_local' => $nextDueUtc?->setTimezone($timezone)->format('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runDue(string $moduleName): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
foreach ($this->statuses($moduleName) as $task) {
|
||||||
|
if (empty($task['enabled']) || empty($task['is_due']) || !empty($task['parse_error'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->modules->hasFunction($moduleName, (string) $task['callback'])) {
|
||||||
|
$results[] = [
|
||||||
|
'task' => $task['name'],
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Callback nicht registriert.',
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->acquireLock($moduleName, (string) $task['name'], (int) $task['lock_minutes'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startedAt = gmdate('Y-m-d H:i:s');
|
||||||
|
$scheduledForUtc = trim((string) ($task['previous_due_at'] ?? ''));
|
||||||
|
$timezone = $this->safeTimezone((string) ($task['timezone'] ?? 'UTC'));
|
||||||
|
|
||||||
|
$this->persistState($moduleName, (string) $task['name'], [
|
||||||
|
'last_started_at' => $startedAt,
|
||||||
|
'last_status' => 'running',
|
||||||
|
'last_message' => 'Cron-Lauf gestartet.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->modules->call($moduleName, (string) $task['callback'], [
|
||||||
|
'task' => $task,
|
||||||
|
'trigger' => 'cron_runner',
|
||||||
|
'started_at' => $startedAt,
|
||||||
|
'scheduled_for_utc' => $scheduledForUtc,
|
||||||
|
'scheduled_for_local' => $scheduledForUtc !== ''
|
||||||
|
? $this->parseUtc($scheduledForUtc)?->setTimezone($timezone)?->format('Y-m-d H:i:s')
|
||||||
|
: null,
|
||||||
|
'timezone' => $timezone->getName(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ok = !is_array($result) || !array_key_exists('ok', $result) || !empty($result['ok']);
|
||||||
|
$skipped = is_array($result) && !empty($result['skipped']);
|
||||||
|
$message = is_array($result) ? trim((string) ($result['message'] ?? '')) : '';
|
||||||
|
$finishedAt = gmdate('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'last_finished_at' => $finishedAt,
|
||||||
|
'last_status' => $skipped ? 'skipped' : ($ok ? 'success' : 'error'),
|
||||||
|
'last_message' => $message,
|
||||||
|
'lock_until' => null,
|
||||||
|
'last_scheduled_for' => $scheduledForUtc !== '' ? $scheduledForUtc : null,
|
||||||
|
];
|
||||||
|
if ($ok && !$skipped) {
|
||||||
|
$payload['last_success_at'] = $finishedAt;
|
||||||
|
}
|
||||||
|
$this->persistState($moduleName, (string) $task['name'], $payload);
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'task' => $task['name'],
|
||||||
|
'ok' => $ok,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$finishedAt = gmdate('Y-m-d H:i:s');
|
||||||
|
$this->persistState($moduleName, (string) $task['name'], [
|
||||||
|
'last_finished_at' => $finishedAt,
|
||||||
|
'last_status' => 'error',
|
||||||
|
'last_message' => $e->getMessage(),
|
||||||
|
'lock_until' => null,
|
||||||
|
'last_scheduled_for' => $scheduledForUtc !== '' ? $scheduledForUtc : null,
|
||||||
|
]);
|
||||||
|
$results[] = [
|
||||||
|
'task' => $task['name'],
|
||||||
|
'ok' => false,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function jobConfig(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']] : [];
|
||||||
|
$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'] : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchStates(string $moduleName): array
|
||||||
|
{
|
||||||
|
if (!$this->pdo) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT *
|
||||||
|
FROM nexus_module_cron_runs
|
||||||
|
WHERE module_name = :module_name'
|
||||||
|
);
|
||||||
|
$stmt->execute(['module_name' => $moduleName]);
|
||||||
|
|
||||||
|
$states = [];
|
||||||
|
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
|
||||||
|
$name = trim((string) ($row['job_name'] ?? ''));
|
||||||
|
if ($name !== '') {
|
||||||
|
$states[$name] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $states;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function acquireLock(string $moduleName, string $jobName, int $lockMinutes): bool
|
||||||
|
{
|
||||||
|
if (!$this->pdo) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$states = $this->fetchStates($moduleName);
|
||||||
|
$state = $states[$jobName] ?? [];
|
||||||
|
if ($this->isLockActive($state, time())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockUntil = gmdate('Y-m-d H:i:s', time() + ($lockMinutes * 60));
|
||||||
|
$this->persistState($moduleName, $jobName, [
|
||||||
|
'lock_until' => $lockUntil,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function persistState(string $moduleName, string $jobName, array $values): void
|
||||||
|
{
|
||||||
|
if (!$this->pdo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = $this->fetchStates($moduleName)[$jobName] ?? [];
|
||||||
|
$payload = array_merge($current, $values, [
|
||||||
|
'module_name' => $moduleName,
|
||||||
|
'job_name' => $jobName,
|
||||||
|
'updated_at' => gmdate('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
'module_name' => $moduleName,
|
||||||
|
'job_name' => $jobName,
|
||||||
|
'last_scheduled_for' => $this->nullOrString($payload['last_scheduled_for'] ?? null),
|
||||||
|
'last_started_at' => $this->nullOrString($payload['last_started_at'] ?? null),
|
||||||
|
'last_finished_at' => $this->nullOrString($payload['last_finished_at'] ?? null),
|
||||||
|
'last_success_at' => $this->nullOrString($payload['last_success_at'] ?? null),
|
||||||
|
'last_status' => $this->nullOrString($payload['last_status'] ?? null),
|
||||||
|
'last_message' => $this->nullOrString($payload['last_message'] ?? null),
|
||||||
|
'lock_until' => $this->nullOrString($payload['lock_until'] ?? null),
|
||||||
|
'updated_at' => $payload['updated_at'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$updateStmt = $this->pdo->prepare(
|
||||||
|
'UPDATE nexus_module_cron_runs
|
||||||
|
SET last_scheduled_for = :last_scheduled_for,
|
||||||
|
last_started_at = :last_started_at,
|
||||||
|
last_finished_at = :last_finished_at,
|
||||||
|
last_success_at = :last_success_at,
|
||||||
|
last_status = :last_status,
|
||||||
|
last_message = :last_message,
|
||||||
|
lock_until = :lock_until,
|
||||||
|
updated_at = :updated_at
|
||||||
|
WHERE module_name = :module_name
|
||||||
|
AND job_name = :job_name'
|
||||||
|
);
|
||||||
|
$updateStmt->execute($params);
|
||||||
|
if ($updateStmt->rowCount() > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$insertStmt = $this->pdo->prepare(
|
||||||
|
'INSERT INTO nexus_module_cron_runs (
|
||||||
|
module_name,
|
||||||
|
job_name,
|
||||||
|
last_scheduled_for,
|
||||||
|
last_started_at,
|
||||||
|
last_finished_at,
|
||||||
|
last_success_at,
|
||||||
|
last_status,
|
||||||
|
last_message,
|
||||||
|
lock_until,
|
||||||
|
updated_at
|
||||||
|
) VALUES (
|
||||||
|
:module_name,
|
||||||
|
:job_name,
|
||||||
|
:last_scheduled_for,
|
||||||
|
:last_started_at,
|
||||||
|
:last_finished_at,
|
||||||
|
:last_success_at,
|
||||||
|
:last_status,
|
||||||
|
:last_message,
|
||||||
|
:lock_until,
|
||||||
|
:updated_at
|
||||||
|
)'
|
||||||
|
);
|
||||||
|
$insertStmt->execute($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullOrString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
return $value !== '' ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function settingBool(mixed $value, bool $default): bool
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
return in_array((string) $value, ['1', 'true', 'yes', 'on'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isLockActive(array $state, int $nowTs): bool
|
||||||
|
{
|
||||||
|
$lockUntil = trim((string) ($state['lock_until'] ?? ''));
|
||||||
|
if ($lockUntil === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockTs = strtotime($lockUntil);
|
||||||
|
return $lockTs !== false && $lockTs > $nowTs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseUtc(string $value): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
$value = trim($value);
|
||||||
|
if ($value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeTimezone(string $timezone): DateTimeZone
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return new DateTimeZone(trim($timezone) !== '' ? trim($timezone) : 'UTC');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return new DateTimeZone('UTC');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ final class ModuleManager
|
|||||||
private array $modules = [];
|
private array $modules = [];
|
||||||
private array $callbacks = [];
|
private array $callbacks = [];
|
||||||
private ?ModuleTaskScheduler $taskScheduler = null;
|
private ?ModuleTaskScheduler $taskScheduler = null;
|
||||||
|
private ?ModuleCronScheduler $cronScheduler = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ?\PDO $basePdo,
|
private ?\PDO $basePdo,
|
||||||
@@ -259,6 +260,21 @@ final class ModuleManager
|
|||||||
return $this->taskScheduler()->runDue($name);
|
return $this->taskScheduler()->runDue($name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function cronTasks(string $name): array
|
||||||
|
{
|
||||||
|
return $this->cronScheduler()->definitions($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cronTaskStatuses(string $name): array
|
||||||
|
{
|
||||||
|
return $this->cronScheduler()->statuses($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runDueCronTasks(string $name): array
|
||||||
|
{
|
||||||
|
return $this->cronScheduler()->runDue($name);
|
||||||
|
}
|
||||||
|
|
||||||
private function scanModules(): void
|
private function scanModules(): void
|
||||||
{
|
{
|
||||||
$this->modules = [];
|
$this->modules = [];
|
||||||
@@ -291,6 +307,7 @@ final class ModuleManager
|
|||||||
'description' => $data['description'] ?? '',
|
'description' => $data['description'] ?? '',
|
||||||
'setup' => $data['setup'] ?? [],
|
'setup' => $data['setup'] ?? [],
|
||||||
'interval_tasks' => $data['interval_tasks'] ?? [],
|
'interval_tasks' => $data['interval_tasks'] ?? [],
|
||||||
|
'cron_tasks' => $data['cron_tasks'] ?? [],
|
||||||
'menu' => $data['menu'] ?? [],
|
'menu' => $data['menu'] ?? [],
|
||||||
'sidebar' => $data['sidebar'] ?? [],
|
'sidebar' => $data['sidebar'] ?? [],
|
||||||
'db_defaults' => $data['db_defaults'] ?? [],
|
'db_defaults' => $data['db_defaults'] ?? [],
|
||||||
@@ -496,4 +513,13 @@ final class ModuleManager
|
|||||||
|
|
||||||
return $this->taskScheduler;
|
return $this->taskScheduler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function cronScheduler(): ModuleCronScheduler
|
||||||
|
{
|
||||||
|
if (!$this->cronScheduler instanceof ModuleCronScheduler) {
|
||||||
|
$this->cronScheduler = new ModuleCronScheduler($this, $this->basePdo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->cronScheduler;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ final class ModuleTaskScheduler
|
|||||||
}
|
}
|
||||||
$intervalHours = max(1.0, $intervalHours);
|
$intervalHours = max(1.0, $intervalHours);
|
||||||
|
|
||||||
$referenceAt = trim((string) ($state['last_success_at'] ?? $state['last_finished_at'] ?? ''));
|
$referenceAt = trim((string) ($state['last_success_at'] ?? ''));
|
||||||
$referenceTs = $referenceAt !== '' ? strtotime($referenceAt) : false;
|
$referenceTs = $referenceAt !== '' ? strtotime($referenceAt) : false;
|
||||||
$nextDueTs = $referenceTs !== false ? ((int) $referenceTs + (int) round($intervalHours * 3600)) : null;
|
$nextDueTs = $referenceTs !== false ? ((int) $referenceTs + (int) round($intervalHours * 3600)) : null;
|
||||||
$isLocked = $this->isLockActive($state, $nowTs);
|
$isLocked = $this->isLockActive($state, $nowTs);
|
||||||
@@ -125,16 +125,17 @@ final class ModuleTaskScheduler
|
|||||||
'started_at' => $startedAt,
|
'started_at' => $startedAt,
|
||||||
]);
|
]);
|
||||||
$ok = !is_array($result) || !array_key_exists('ok', $result) || !empty($result['ok']);
|
$ok = !is_array($result) || !array_key_exists('ok', $result) || !empty($result['ok']);
|
||||||
|
$skipped = is_array($result) && !empty($result['skipped']);
|
||||||
$message = is_array($result) ? trim((string) ($result['message'] ?? '')) : '';
|
$message = is_array($result) ? trim((string) ($result['message'] ?? '')) : '';
|
||||||
$finishedAt = gmdate('Y-m-d H:i:s');
|
$finishedAt = gmdate('Y-m-d H:i:s');
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'last_finished_at' => $finishedAt,
|
'last_finished_at' => $finishedAt,
|
||||||
'last_status' => $ok ? 'success' : 'error',
|
'last_status' => $skipped ? 'skipped' : ($ok ? 'success' : 'error'),
|
||||||
'last_message' => $message,
|
'last_message' => $message,
|
||||||
'lock_until' => null,
|
'lock_until' => null,
|
||||||
];
|
];
|
||||||
if ($ok) {
|
if ($ok && !$skipped) {
|
||||||
$payload['last_success_at'] = $finishedAt;
|
$payload['last_success_at'] = $finishedAt;
|
||||||
}
|
}
|
||||||
$this->persistState($moduleName, (string) $task['name'], $payload);
|
$this->persistState($moduleName, (string) $task['name'], $payload);
|
||||||
|
|||||||
38
tools/module_scheduler_run.php
Normal file
38
tools/module_scheduler_run.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config/fileload.php';
|
||||||
|
|
||||||
|
$targetModule = trim((string) ($argv[1] ?? ''));
|
||||||
|
$modules = app()->modules()->all();
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($modules as $name => $meta) {
|
||||||
|
if (!is_string($name) || $name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($targetModule !== '' && $targetModule !== $name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (empty($meta['enabled'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$taskDefs = app()->modules()->intervalTasks($name);
|
||||||
|
if ($taskDefs === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results[$name] = [
|
||||||
|
'interval' => app()->modules()->runDueIntervalTasks($name),
|
||||||
|
'cron' => app()->modules()->runDueCronTasks($name),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'ran_at_utc' => gmdate('Y-m-d H:i:s'),
|
||||||
|
'module' => $targetModule !== '' ? $targetModule : null,
|
||||||
|
'results' => $results,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;
|
||||||
Reference in New Issue
Block a user