|
|
|
|
@@ -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'))),
|
|
|
|
|
$jobPayload = is_array($postedSchedulerJobs[$cronName] ?? null) ? $postedSchedulerJobs[$cronName] : [];
|
|
|
|
|
$entriesPayload = is_array($jobPayload['entries'] ?? null) ? $jobPayload['entries'] : [];
|
|
|
|
|
$entries = [];
|
|
|
|
|
foreach (array_values($entriesPayload) as $entryPayload) {
|
|
|
|
|
if (!is_array($entryPayload)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$cronExpression = trim((string) ($entryPayload['cron_expression'] ?? ''));
|
|
|
|
|
$timezone = trim((string) ($entryPayload['timezone'] ?? ($current['schedule_timezone'] ?? 'UTC')));
|
|
|
|
|
if ($cronExpression === '' && $timezone === '') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$entries[] = [
|
|
|
|
|
'enabled' => !empty($entryPayload['enabled']),
|
|
|
|
|
'cron_expression' => $cronExpression,
|
|
|
|
|
'timezone' => $timezone !== '' ? $timezone : 'UTC',
|
|
|
|
|
'builder' => [
|
|
|
|
|
'mode' => trim((string) ($_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)),
|
|
|
|
|
'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['cron_jobs'] = $cronJobs;
|
|
|
|
|
$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,56 +807,72 @@ $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
|
|
|
|
|
$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; ?>
|
|
|
|
|
<?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
|
|
|
|
|
$cronConfig = is_array($task['config'] ?? null) ? $task['config'] : [];
|
|
|
|
|
$builder = is_array($cronConfig['builder'] ?? null) ? $cronConfig['builder'] : [];
|
|
|
|
|
?>
|
|
|
|
|
<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="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="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="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="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][month_day]" value="<?= e((string) ($builder['month_day'] ?? 1)) ?>" data-cron-builder-month-day>
|
|
|
|
|
<input type="number" min="1" max="23" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][interval_hours]" value="<?= e((string) ($builder['interval_hours'] ?? 6)) ?>" data-cron-builder-interval-hours>
|
|
|
|
|
</div>
|
|
|
|
|
<small class="muted">Letzter Start: <?= e($formatRunTimestamp((string) ($task['state']['last_started_at'] ?? ''))) ?></small>
|
|
|
|
|
<small class="muted">Letzter Erfolg: <?= e($formatRunTimestamp((string) ($task['state']['last_success_at'] ?? ''))) ?></small>
|
|
|
|
|
<small class="muted">Naechster Lauf UTC: <?= e($formatRunTimestamp((string) ($task['next_due_at'] ?? ''))) ?></small>
|
|
|
|
|
<small class="muted">Naechster Lauf lokal: <?= e((string) (($task['next_due_at_local'] ?? '') !== '' ? $task['next_due_at_local'] : '-')) ?></small>
|
|
|
|
|
<small class="muted">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; ?>
|
|
|
|
|
<?php if ($cronMode === 'multi'): ?>
|
|
|
|
|
<button class="nav-link" type="button" data-remove-scheduler-entry>Eintrag entfernen</button>
|
|
|
|
|
<?php endif; ?>
|
|
|
|
|
</div>
|
|
|
|
|
<?php endforeach; ?>
|
|
|
|
|
</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,56 +1183,72 @@ $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
|
|
|
|
|
$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; ?>
|
|
|
|
|
<?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
|
|
|
|
|
$cronConfig = is_array($task['config'] ?? null) ? $task['config'] : [];
|
|
|
|
|
$builder = is_array($cronConfig['builder'] ?? null) ? $cronConfig['builder'] : [];
|
|
|
|
|
?>
|
|
|
|
|
<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="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="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="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="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][month_day]" value="<?= e((string) ($builder['month_day'] ?? 1)) ?>" data-cron-builder-month-day>
|
|
|
|
|
<input type="number" min="1" max="23" name="scheduler_jobs[<?= e($cronName) ?>][entries][<?= e((string) $entryIndex) ?>][builder][interval_hours]" value="<?= e((string) ($builder['interval_hours'] ?? 6)) ?>" data-cron-builder-interval-hours>
|
|
|
|
|
</div>
|
|
|
|
|
<small class="muted">Letzter Start: <?= e($formatRunTimestamp((string) ($task['state']['last_started_at'] ?? ''))) ?></small>
|
|
|
|
|
<small class="muted">Letzter Erfolg: <?= e($formatRunTimestamp((string) ($task['state']['last_success_at'] ?? ''))) ?></small>
|
|
|
|
|
<small class="muted">Naechster Lauf UTC: <?= e($formatRunTimestamp((string) ($task['next_due_at'] ?? ''))) ?></small>
|
|
|
|
|
<small class="muted">Naechster Lauf lokal: <?= e((string) (($task['next_due_at_local'] ?? '') !== '' ? $task['next_due_at_local'] : '-')) ?></small>
|
|
|
|
|
<small class="muted">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; ?>
|
|
|
|
|
<?php if ($cronMode === 'multi'): ?>
|
|
|
|
|
<button class="nav-link" type="button" data-remove-scheduler-entry>Eintrag entfernen</button>
|
|
|
|
|
<?php endif; ?>
|
|
|
|
|
</div>
|
|
|
|
|
<?php endforeach; ?>
|
|
|
|
|
</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;
|
|
|
|
|
|