2199 lines
96 KiB
PHP
2199 lines
96 KiB
PHP
<?php
|
||
$moduleName = (string)($_GET['module'] ?? '');
|
||
$module = modules()->get($moduleName);
|
||
$error = null;
|
||
$notice = null;
|
||
$testGroup = null;
|
||
$dbTestMessages = [];
|
||
$currentSection = trim((string) ($_GET['section'] ?? 'general'));
|
||
|
||
require_admin();
|
||
|
||
if (!$module) {
|
||
http_response_code(404);
|
||
echo '<div class="card">Modul nicht gefunden.</div>';
|
||
return;
|
||
}
|
||
|
||
$fields = (array)($module['setup']['fields'] ?? []);
|
||
$setupSectionConfig = is_array($module['setup']['sections'] ?? null) ? $module['setup']['sections'] : [];
|
||
$hasGlobalDebugField = false;
|
||
foreach ($fields as $field) {
|
||
if ((string)($field['name'] ?? '') === 'debug_enabled') {
|
||
$hasGlobalDebugField = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!$hasGlobalDebugField) {
|
||
$fields[] = [
|
||
'name' => 'debug_enabled',
|
||
'label' => 'Modul-Debug aktivieren',
|
||
'type' => 'checkbox',
|
||
'help' => 'Wenn aktiv, darf dieses Modul Debug-Daten in den globalen Nexus-Debugstream schreiben.',
|
||
];
|
||
}
|
||
$fieldTypes = [];
|
||
$fieldMeta = [];
|
||
foreach ($fields as $field) {
|
||
$fname = (string)($field['name'] ?? '');
|
||
if ($fname === '') {
|
||
continue;
|
||
}
|
||
$fieldTypes[$fname] = (string)($field['type'] ?? 'text');
|
||
$fieldMeta[$fname] = $field;
|
||
}
|
||
$current = modules()->settings($moduleName);
|
||
$runtimeSettingsEnabled = modules()->hasFunction($moduleName, 'runtime_settings');
|
||
if ($runtimeSettingsEnabled) {
|
||
try {
|
||
$runtimeSettings = module_fn($moduleName, 'runtime_settings');
|
||
if (is_array($runtimeSettings)) {
|
||
$current = array_replace_recursive($current, $runtimeSettings);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$error = $e->getMessage();
|
||
}
|
||
}
|
||
$intervalTaskStatuses = [];
|
||
$cronTaskDefinitions = modules()->cronTasks($moduleName);
|
||
$cronTaskStatuses = [];
|
||
$cronTaskStatusGroups = [];
|
||
$refreshSchedulerState = static function () use ($moduleName, &$intervalTaskStatuses, &$cronTaskStatuses, &$cronTaskStatusGroups): void {
|
||
$intervalTaskStatuses = modules()->intervalTaskStatuses($moduleName);
|
||
$cronTaskStatuses = modules()->cronTaskStatuses($moduleName);
|
||
$cronTaskStatusGroups = [];
|
||
foreach ($cronTaskStatuses as $cronTaskStatus) {
|
||
$cronGroupName = trim((string) ($cronTaskStatus['job_name'] ?? $cronTaskStatus['name'] ?? ''));
|
||
if ($cronGroupName === '') {
|
||
continue;
|
||
}
|
||
$cronTaskStatusGroups[$cronGroupName][] = $cronTaskStatus;
|
||
}
|
||
};
|
||
$refreshSchedulerState();
|
||
$setupActions = modules()->hasFunction($moduleName, 'setup_actions')
|
||
? (array) module_fn($moduleName, 'setup_actions')
|
||
: [];
|
||
$databaseSectionActions = array_values(array_filter($setupActions, static function (mixed $action): bool {
|
||
return is_array($action) && trim((string) ($action['section'] ?? '')) === 'database';
|
||
}));
|
||
$customSectionActions = array_values(array_filter($setupActions, static function (mixed $action): bool {
|
||
return !is_array($action) || trim((string) ($action['section'] ?? '')) !== 'database';
|
||
}));
|
||
$defaults = $module['db_defaults'] ?? [];
|
||
if (empty($current['db']) && is_array($defaults)) {
|
||
$current['db'] = $defaults;
|
||
}
|
||
$metadataDefaults = $module['metadata_db_defaults'] ?? [];
|
||
if (empty($current['metadata_db']) && is_array($metadataDefaults)) {
|
||
$current['metadata_db'] = $metadataDefaults;
|
||
}
|
||
$dbDefaultsByGroup = [
|
||
'db' => is_array($defaults) ? $defaults : [],
|
||
'metadata_db' => is_array($metadataDefaults) ? $metadataDefaults : [],
|
||
];
|
||
$authConfig = is_array($module['auth'] ?? null) ? $module['auth'] : ['required' => false, 'users' => [], 'groups' => []];
|
||
$allowedUsers = [];
|
||
$allowedGroups = [];
|
||
$knownUsers = [];
|
||
$knownGroups = [];
|
||
$manualUsers = [];
|
||
$manualGroups = [];
|
||
|
||
$setNested = function (array &$target, string $path, mixed $value): void {
|
||
$parts = explode('.', $path);
|
||
$last = array_pop($parts);
|
||
$node = &$target;
|
||
foreach ($parts as $part) {
|
||
if (!isset($node[$part]) || !is_array($node[$part])) {
|
||
$node[$part] = [];
|
||
}
|
||
$node = &$node[$part];
|
||
}
|
||
if ($last !== null && $last !== '') {
|
||
$node[$last] = $value;
|
||
}
|
||
};
|
||
|
||
$getNested = function (array $source, string $path): mixed {
|
||
$node = $source;
|
||
foreach (explode('.', $path) as $part) {
|
||
if (!is_array($node) || !array_key_exists($part, $node)) {
|
||
return null;
|
||
}
|
||
$node = $node[$part];
|
||
}
|
||
return $node;
|
||
};
|
||
|
||
$dbGroups = [];
|
||
$fieldsByDbGroup = [];
|
||
$generalFields = [];
|
||
foreach ($fields as $field) {
|
||
$name = (string)($field['name'] ?? '');
|
||
if (!str_contains($name, '.')) {
|
||
continue;
|
||
}
|
||
|
||
[$group, $key] = explode('.', $name, 2);
|
||
if ($key !== 'driver') {
|
||
continue;
|
||
}
|
||
|
||
$label = (string)($field['label'] ?? $group);
|
||
$label = trim(preg_replace('/\s+DB\s+Driver$/i', ' DB', $label) ?? $label);
|
||
if ($group === 'db') {
|
||
$label = 'Custom Datenbank';
|
||
}
|
||
$label = $label !== '' ? $label : $group;
|
||
$dbGroups[$group] = $label;
|
||
}
|
||
|
||
foreach ($fields as $field) {
|
||
$name = (string)($field['name'] ?? '');
|
||
if (str_contains($name, '.')) {
|
||
[$group] = explode('.', $name, 2);
|
||
if (array_key_exists($group, $dbGroups)) {
|
||
$fieldsByDbGroup[$group][] = $field;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$generalFields[] = $field;
|
||
}
|
||
|
||
$generalSetupFields = [];
|
||
$databaseSetupFields = [];
|
||
$cronSetupFields = [];
|
||
$customSetupFields = [];
|
||
foreach ($generalFields as $field) {
|
||
$fieldName = (string)($field['name'] ?? '');
|
||
if ($fieldName === 'debug_enabled') {
|
||
$generalSetupFields[] = $field;
|
||
continue;
|
||
}
|
||
if ($fieldName === 'use_separate_db') {
|
||
$databaseSetupFields[] = $field;
|
||
continue;
|
||
}
|
||
if ($fieldName === 'schedule_timezone') {
|
||
$cronSetupFields[] = $field;
|
||
continue;
|
||
}
|
||
$customSetupFields[] = $field;
|
||
}
|
||
|
||
$driverOptions = [
|
||
'pgsql' => 'PostgreSQL',
|
||
'mysql' => 'MySQL / MariaDB',
|
||
'sqlite' => 'SQLite',
|
||
];
|
||
|
||
$timezoneOptions = modules()->timezones();
|
||
|
||
$describeDbConfig = static function (array $dbConfig): string {
|
||
$driver = (string)($dbConfig['driver'] ?? '');
|
||
$host = (string)($dbConfig['host'] ?? '');
|
||
$port = (string)($dbConfig['port'] ?? '');
|
||
|
||
return 'Aktive Einstellung: Treiber ' . ($driver !== '' ? $driver : 'nicht gesetzt')
|
||
. ', Host ' . ($host !== '' ? $host : 'nicht gesetzt')
|
||
. ', Port ' . ($port !== '' ? $port : 'nicht gesetzt') . '.';
|
||
};
|
||
|
||
$describeDbDefaults = static function (array $dbDefaults): string {
|
||
$driver = (string)($dbDefaults['driver'] ?? '');
|
||
$host = (string)($dbDefaults['host'] ?? '');
|
||
$port = (string)($dbDefaults['port'] ?? '');
|
||
|
||
if ($driver === '' && $host === '' && $port === '') {
|
||
return '';
|
||
}
|
||
|
||
return 'Standard: Treiber ' . ($driver !== '' ? $driver : 'nicht gesetzt')
|
||
. ', Host ' . ($host !== '' ? $host : 'nicht gesetzt')
|
||
. ', Port ' . ($port !== '' ? $port : 'nicht gesetzt') . '.';
|
||
};
|
||
|
||
$dbConfigWarning = static function (array $dbConfig): ?string {
|
||
$driver = (string)($dbConfig['driver'] ?? '');
|
||
$port = (string)($dbConfig['port'] ?? '');
|
||
|
||
if ($driver === 'pgsql' && $port === '3306') {
|
||
return 'Port 3306 ist typisch fuer MySQL/MariaDB, aber als Treiber ist PostgreSQL ausgewaehlt. Bitte den Treiber auf MySQL / MariaDB stellen.';
|
||
}
|
||
|
||
if ($driver === 'mysql' && $port === '5432') {
|
||
return 'Port 5432 ist typisch fuer PostgreSQL, aber als Treiber ist MySQL / MariaDB ausgewaehlt. Bitte Port oder Treiber pruefen.';
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
$dbConfigHint = static function (string $group, array $dbConfig, array $dbDefaults): ?string {
|
||
if ($group !== 'metadata_db') {
|
||
return null;
|
||
}
|
||
|
||
$driver = (string)($dbConfig['driver'] ?? '');
|
||
$host = (string)($dbConfig['host'] ?? '');
|
||
$port = (string)($dbConfig['port'] ?? '');
|
||
$defaultDriver = (string)($dbDefaults['driver'] ?? '');
|
||
$defaultHost = (string)($dbDefaults['host'] ?? '');
|
||
$defaultPort = (string)($dbDefaults['port'] ?? '');
|
||
|
||
if ($driver !== $defaultDriver || $host !== $defaultHost || $port !== $defaultPort) {
|
||
return 'Diese Verbindung ist fuer Nexus-eigene DHCP-Zusatzinfos gedacht, nicht fuer die Nexus-App/Base-DB. Erwartet wird normalerweise: '
|
||
. ($defaultDriver !== '' ? $defaultDriver : 'Treiber offen')
|
||
. ' auf '
|
||
. ($defaultHost !== '' ? $defaultHost : 'Host offen')
|
||
. ':'
|
||
. ($defaultPort !== '' ? $defaultPort : 'Port offen')
|
||
. '.';
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
$fetchJsonWithApiKey = static function (string $url, string $apiKey, int $timeout = 10): array {
|
||
$headers = [
|
||
'Accept: application/json',
|
||
'x-api-key: ' . $apiKey,
|
||
];
|
||
|
||
$responseBody = null;
|
||
$httpCode = 0;
|
||
$curlError = '';
|
||
|
||
if (function_exists('curl_init')) {
|
||
$ch = curl_init($url);
|
||
if ($ch !== false) {
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_FOLLOWLOCATION => true,
|
||
CURLOPT_TIMEOUT => $timeout,
|
||
CURLOPT_CONNECTTIMEOUT => min(5, $timeout),
|
||
CURLOPT_HTTPHEADER => $headers,
|
||
]);
|
||
$responseBody = curl_exec($ch);
|
||
$curlError = curl_error($ch);
|
||
$httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||
curl_close($ch);
|
||
}
|
||
}
|
||
|
||
if (!is_string($responseBody) || $responseBody === '') {
|
||
$context = stream_context_create([
|
||
'http' => [
|
||
'method' => 'GET',
|
||
'timeout' => $timeout,
|
||
'header' => implode("\r\n", $headers) . "\r\n",
|
||
],
|
||
]);
|
||
$responseBody = @file_get_contents($url, false, $context);
|
||
}
|
||
|
||
if (!is_string($responseBody) || $responseBody === '') {
|
||
return [
|
||
'ok' => false,
|
||
'message' => 'Abruf fehlgeschlagen.'
|
||
. ($curlError !== '' ? ' ' . $curlError : '')
|
||
. ($httpCode > 0 ? ' HTTP ' . $httpCode : ''),
|
||
];
|
||
}
|
||
|
||
$decoded = json_decode($responseBody, true);
|
||
if (!is_array($decoded)) {
|
||
return [
|
||
'ok' => false,
|
||
'message' => 'Antwort ist kein gueltiges JSON.',
|
||
];
|
||
}
|
||
|
||
foreach (['error', 'message', 'detail'] as $errorKey) {
|
||
if (isset($decoded[$errorKey]) && is_string($decoded[$errorKey]) && trim($decoded[$errorKey]) !== '') {
|
||
return [
|
||
'ok' => false,
|
||
'message' => trim((string) $decoded[$errorKey]),
|
||
];
|
||
}
|
||
}
|
||
|
||
return [
|
||
'ok' => true,
|
||
'data' => $decoded,
|
||
];
|
||
};
|
||
|
||
$normalizeDriver = static function (mixed $value): mixed {
|
||
if (!is_string($value)) {
|
||
return $value;
|
||
}
|
||
|
||
$normalized = strtolower(trim($value));
|
||
return match ($normalized) {
|
||
'postgres', 'postgresql' => 'pgsql',
|
||
'mariadb', 'mysql/mariadb', 'mysql / mariadb' => 'mysql',
|
||
default => $normalized,
|
||
};
|
||
};
|
||
|
||
$formatRunTimestamp = static function (?string $value, ?string $timezone = null): string {
|
||
$value = trim((string) $value);
|
||
if ($value === '') {
|
||
return '-';
|
||
}
|
||
|
||
try {
|
||
$dt = new DateTimeImmutable($value, new DateTimeZone('UTC'));
|
||
$targetTz = trim((string) $timezone) !== '' ? new DateTimeZone((string) $timezone) : new DateTimeZone(date_default_timezone_get());
|
||
return $dt->setTimezone($targetTz)->format('Y-m-d H:i:s');
|
||
} catch (\Throwable) {
|
||
$ts = strtotime($value);
|
||
if ($ts === false) {
|
||
return $value;
|
||
}
|
||
return date('Y-m-d H:i:s', $ts);
|
||
}
|
||
};
|
||
|
||
$extractSchedulerJobs = static function (array $postedSchedulerJobs, array $cronTaskDefinitions, array $current): array {
|
||
$schedulerJobs = [];
|
||
foreach ($cronTaskDefinitions as $cronTask) {
|
||
if (!is_array($cronTask)) {
|
||
continue;
|
||
}
|
||
$cronName = trim((string) ($cronTask['name'] ?? ''));
|
||
if ($cronName === '') {
|
||
continue;
|
||
}
|
||
|
||
$jobPayload = is_array($postedSchedulerJobs[$cronName] ?? null) ? $postedSchedulerJobs[$cronName] : [];
|
||
$entriesPayload = is_array($jobPayload['entries'] ?? null) ? $jobPayload['entries'] : [];
|
||
$entries = [];
|
||
foreach (array_values($entriesPayload) as $entryPayload) {
|
||
if (!is_array($entryPayload)) {
|
||
continue;
|
||
}
|
||
$cronExpression = trim((string) ($entryPayload['cron_expression'] ?? ''));
|
||
$timezone = trim((string) ($entryPayload['timezone'] ?? ($current['schedule_timezone'] ?? 'UTC')));
|
||
if ($cronExpression === '' && $timezone === '') {
|
||
continue;
|
||
}
|
||
$entries[] = [
|
||
'enabled' => !empty($entryPayload['enabled']),
|
||
'cron_expression' => $cronExpression,
|
||
'timezone' => $timezone !== '' ? $timezone : 'UTC',
|
||
'builder' => [
|
||
'mode' => trim((string) ($entryPayload['builder']['mode'] ?? 'builder')),
|
||
'kind' => trim((string) ($entryPayload['builder']['kind'] ?? 'daily')),
|
||
'time' => trim((string) ($entryPayload['builder']['time'] ?? '18:00')),
|
||
'interval_days' => max(1, (int) ($entryPayload['builder']['interval_days'] ?? 2)),
|
||
'weekday' => trim((string) ($entryPayload['builder']['weekday'] ?? '1')),
|
||
'month_day' => max(1, min(31, (int) ($entryPayload['builder']['month_day'] ?? 1))),
|
||
'interval_hours' => max(1, min(23, (int) ($entryPayload['builder']['interval_hours'] ?? 6))),
|
||
],
|
||
];
|
||
}
|
||
|
||
if ((string) ($cronTask['mode'] ?? 'single') !== 'multi' && $entries !== []) {
|
||
$entries = [array_values($entries)[0]];
|
||
}
|
||
|
||
$schedulerJobs[$cronName] = ['entries' => $entries];
|
||
}
|
||
|
||
return $schedulerJobs;
|
||
};
|
||
|
||
$cronWeekdays = [
|
||
'0' => 'Sonntag',
|
||
'1' => 'Montag',
|
||
'2' => 'Dienstag',
|
||
'3' => 'Mittwoch',
|
||
'4' => 'Donnerstag',
|
||
'5' => 'Freitag',
|
||
'6' => 'Samstag',
|
||
];
|
||
|
||
$renderField = function (array $field) use (&$current, $getNested, $driverOptions): void {
|
||
$name = (string)($field['name'] ?? '');
|
||
if ($name === '') {
|
||
return;
|
||
}
|
||
$label = (string)($field['label'] ?? $name);
|
||
$type = (string)($field['type'] ?? 'text');
|
||
$required = !empty($field['required']);
|
||
$help = (string)($field['help'] ?? $field['description'] ?? '');
|
||
$options = is_array($field['options'] ?? null) ? $field['options'] : [];
|
||
$postKey = str_replace('.', '_', $name);
|
||
|
||
$value = '';
|
||
if ($name === 'kea_auto_init') {
|
||
$value = !empty($current[$name]) ? '1' : '0';
|
||
} elseif (str_contains($name, '.')) {
|
||
$value = $getNested($current, $name);
|
||
} else {
|
||
$value = $current[$name] ?? '';
|
||
}
|
||
if (is_array($value)) {
|
||
$value = $type === 'multiselect'
|
||
? array_values(array_map('strval', $value))
|
||
: implode(', ', array_values(array_map('strval', $value)));
|
||
} else {
|
||
$value = (string) $value;
|
||
}
|
||
?>
|
||
<label class="setup-field muted">
|
||
<span><?= e($label) ?></span>
|
||
<?php if (str_ends_with($name, '.driver')): ?>
|
||
<select name="<?= e($postKey) ?>" <?= $required ? 'required' : '' ?>>
|
||
<option value="">Bitte waehlen</option>
|
||
<?php foreach ($driverOptions as $driver => $driverLabel): ?>
|
||
<option value="<?= e($driver) ?>" <?= $value === $driver ? 'selected' : '' ?>><?= e($driverLabel) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<?php elseif ($type === 'select' && $options !== []): ?>
|
||
<select name="<?= e($postKey) ?>" <?= $required ? 'required' : '' ?>>
|
||
<option value="">Bitte waehlen</option>
|
||
<?php foreach ($options as $optionValue => $optionLabel): ?>
|
||
<?php
|
||
if (is_int($optionValue) && is_array($optionLabel)) {
|
||
$optionValue = (string) ($optionLabel['value'] ?? '');
|
||
$optionLabel = (string) ($optionLabel['label'] ?? $optionValue);
|
||
} else {
|
||
$optionValue = (string) $optionValue;
|
||
$optionLabel = (string) $optionLabel;
|
||
}
|
||
?>
|
||
<option value="<?= e($optionValue) ?>" <?= $value === $optionValue ? 'selected' : '' ?>><?= e($optionLabel) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<?php elseif ($type === 'multiselect' && $options !== []): ?>
|
||
<?php $selectedValues = is_array($value) ? $value : []; ?>
|
||
<select name="<?= e($postKey) ?>[]" multiple size="<?= e((string) min(8, max(4, count($options)))) ?>" <?= $required ? 'required' : '' ?>>
|
||
<?php foreach ($options as $optionValue => $optionLabel): ?>
|
||
<?php
|
||
if (is_int($optionValue) && is_array($optionLabel)) {
|
||
$optionValue = (string) ($optionLabel['value'] ?? '');
|
||
$optionLabel = (string) ($optionLabel['label'] ?? $optionValue);
|
||
} else {
|
||
$optionValue = (string) $optionValue;
|
||
$optionLabel = (string) $optionLabel;
|
||
}
|
||
?>
|
||
<option value="<?= e($optionValue) ?>" <?= in_array($optionValue, $selectedValues, true) ? 'selected' : '' ?>><?= e($optionLabel) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<?php elseif ($type === 'textarea'): ?>
|
||
<textarea name="<?= e($postKey) ?>" rows="3" <?= $required ? 'required' : '' ?>><?= e($value) ?></textarea>
|
||
<?php elseif ($type === 'checkbox'): ?>
|
||
<input type="checkbox" name="<?= e($postKey) ?>" value="1" <?= $value === '1' ? 'checked' : '' ?>>
|
||
<?php elseif ($name === 'schedule_timezone' || str_ends_with($name, '_timezone') || str_ends_with($name, '.timezone')): ?>
|
||
<input type="text" name="<?= e($postKey) ?>" value="<?= e($value) ?>" list="timezone-options" autocomplete="off" <?= $required ? 'required' : '' ?>>
|
||
<?php else: ?>
|
||
<input type="<?= e($type) ?>" name="<?= e($postKey) ?>" value="<?= e($value) ?>" <?= $required ? 'required' : '' ?>>
|
||
<?php endif; ?>
|
||
<?php if ($help !== ''): ?>
|
||
<small class="muted"><?= e($help) ?></small>
|
||
<?php endif; ?>
|
||
</label>
|
||
<?php
|
||
};
|
||
|
||
$hasDatabaseSection = array_key_exists('database', $setupSectionConfig)
|
||
? !empty($setupSectionConfig['database'])
|
||
: $dbGroups !== [];
|
||
$hasCustomSection = $customSetupFields !== [] || $customSectionActions !== [];
|
||
$allowedSetupSections = ['general', 'access', 'cron'];
|
||
if ($hasDatabaseSection) {
|
||
$allowedSetupSections[] = 'database';
|
||
}
|
||
if ($hasCustomSection) {
|
||
$allowedSetupSections[] = 'custom';
|
||
}
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$isSchedulerAutosave = isset($_POST['scheduler_autosave']) && (string) $_POST['scheduler_autosave'] === '1';
|
||
$isSchedulerTest = isset($_POST['scheduler_test']) && (string) $_POST['scheduler_test'] === '1';
|
||
$submittedSetupSection = trim((string) ($_POST['setup_section'] ?? $currentSection));
|
||
if (!in_array($submittedSetupSection, $allowedSetupSections, true)) {
|
||
$submittedSetupSection = $currentSection;
|
||
}
|
||
$payload = [];
|
||
$sectionFieldNames = match ($submittedSetupSection) {
|
||
'general' => array_map(static fn (array $field): string => (string) ($field['name'] ?? ''), $generalSetupFields),
|
||
'cron' => array_map(static fn (array $field): string => (string) ($field['name'] ?? ''), $cronSetupFields),
|
||
'custom' => array_map(static fn (array $field): string => (string) ($field['name'] ?? ''), $customSetupFields),
|
||
'database' => array_values(array_filter(array_merge(
|
||
array_map(static fn (array $field): string => (string) ($field['name'] ?? ''), $databaseSetupFields),
|
||
array_reduce($fieldsByDbGroup, static function (array $carry, array $groupFields): array {
|
||
foreach ($groupFields as $field) {
|
||
$carry[] = (string) ($field['name'] ?? '');
|
||
}
|
||
return $carry;
|
||
}, [])
|
||
), static fn (string $name): bool => $name !== '')),
|
||
default => [],
|
||
};
|
||
$sectionFieldLookup = array_fill_keys(array_values(array_filter($sectionFieldNames, static fn (string $name): bool => $name !== '')), true);
|
||
|
||
if ($isSchedulerAutosave || $isSchedulerTest) {
|
||
if ($cronTaskDefinitions !== []) {
|
||
$postedSchedulerJobs = is_array($_POST['scheduler_jobs'] ?? null) ? $_POST['scheduler_jobs'] : [];
|
||
$current['scheduler_jobs'] = $extractSchedulerJobs($postedSchedulerJobs, $cronTaskDefinitions, $current);
|
||
}
|
||
modules()->saveSettings($moduleName, $current);
|
||
$current = modules()->settings($moduleName);
|
||
$refreshSchedulerState();
|
||
|
||
$testResult = null;
|
||
if ($isSchedulerTest) {
|
||
$jobName = trim((string) ($_POST['scheduler_job_name'] ?? ''));
|
||
$entryIndex = max(0, (int) ($_POST['scheduler_entry_index'] ?? 0));
|
||
$testResult = modules()->runCronTaskNow($moduleName, $jobName, $entryIndex);
|
||
$refreshSchedulerState();
|
||
}
|
||
|
||
while (ob_get_level() > 0) {
|
||
ob_end_clean();
|
||
}
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
echo json_encode([
|
||
'ok' => true,
|
||
'message' => $isSchedulerTest
|
||
? (string) ($testResult['message'] ?? 'Cron-Test ausgefuehrt.')
|
||
: 'Scheduler gespeichert.',
|
||
'scheduler_jobs' => $current['scheduler_jobs'] ?? [],
|
||
'statuses' => $cronTaskStatuses,
|
||
'test_result' => $testResult,
|
||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
exit;
|
||
}
|
||
|
||
foreach ($fields as $field) {
|
||
$name = (string)($field['name'] ?? '');
|
||
if ($name === '') {
|
||
continue;
|
||
}
|
||
if (!isset($sectionFieldLookup[$name])) {
|
||
continue;
|
||
}
|
||
$type = (string)($field['type'] ?? 'text');
|
||
$postKey = str_replace('.', '_', $name);
|
||
$value = $_POST[$postKey] ?? null;
|
||
|
||
if ($type === 'checkbox') {
|
||
$value = isset($_POST[$postKey]) ? '1' : '0';
|
||
}
|
||
if (is_array($value) && $type === 'multiselect') {
|
||
$value = array_values(array_filter(array_map(
|
||
static fn (mixed $item): string => trim((string) $item),
|
||
$value
|
||
), static fn (string $item): bool => $item !== ''));
|
||
} elseif (is_array($value)) {
|
||
continue;
|
||
}
|
||
$value = is_string($value) ? trim($value) : $value;
|
||
if (str_ends_with($name, '.driver')) {
|
||
$value = $normalizeDriver($value);
|
||
}
|
||
|
||
if ($name === 'kea_auto_init') {
|
||
$payload[$name] = $value === '1';
|
||
continue;
|
||
}
|
||
|
||
if (str_contains($name, '.')) {
|
||
$setNested($payload, $name, $value);
|
||
continue;
|
||
}
|
||
|
||
$payload[$name] = $value;
|
||
}
|
||
|
||
$current = array_replace_recursive($current, $payload);
|
||
|
||
if ($cronTaskDefinitions !== []) {
|
||
$postedSchedulerJobs = is_array($_POST['scheduler_jobs'] ?? null) ? $_POST['scheduler_jobs'] : [];
|
||
$schedulerJobs = $extractSchedulerJobs($postedSchedulerJobs, $cronTaskDefinitions, $current);
|
||
$current['scheduler_jobs'] = $schedulerJobs;
|
||
}
|
||
|
||
$postedTestGroup = (string)($_POST['test_db'] ?? '');
|
||
$postedResetGroup = (string)($_POST['reset_db'] ?? '');
|
||
$postedSetupAction = trim((string)($_POST['module_setup_action'] ?? ''));
|
||
if ($postedSetupAction !== '') {
|
||
if (!modules()->hasFunction($moduleName, 'run_setup_action')) {
|
||
$error = 'Diese Setup-Aktion wird vom Modul nicht unterstuetzt.';
|
||
} else {
|
||
try {
|
||
$actionResult = module_fn($moduleName, 'run_setup_action', $postedSetupAction);
|
||
$current = modules()->settings($moduleName);
|
||
$notice = 'Setup-Aktion ausgefuehrt.';
|
||
if (is_array($actionResult)) {
|
||
if (isset($actionResult['message']) && is_string($actionResult['message']) && trim($actionResult['message']) !== '') {
|
||
$notice = trim((string) $actionResult['message']);
|
||
} elseif (isset($actionResult['synced_count'])) {
|
||
$notice = 'Waehrungskatalog synchronisiert. ' . (int) $actionResult['synced_count'] . ' Waehrungen verarbeitet.';
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$error = $e->getMessage();
|
||
}
|
||
}
|
||
} elseif ($postedResetGroup !== '') {
|
||
$testGroup = $postedResetGroup;
|
||
if (!array_key_exists($postedResetGroup, $dbGroups)) {
|
||
$error = 'Unbekannte Datenbank-Konfiguration.';
|
||
} elseif (($dbDefaultsByGroup[$postedResetGroup] ?? []) === []) {
|
||
$dbTestMessages[$postedResetGroup] = [
|
||
'type' => 'error',
|
||
'text' => 'Fuer diese Datenbank sind keine Standardwerte hinterlegt.',
|
||
];
|
||
} else {
|
||
$current[$postedResetGroup] = $dbDefaultsByGroup[$postedResetGroup];
|
||
$dbTestMessages[$postedResetGroup] = [
|
||
'type' => 'success',
|
||
'text' => 'Standardwerte geladen. Bitte pruefen und speichern.',
|
||
];
|
||
}
|
||
} elseif ($postedTestGroup !== '') {
|
||
$testGroup = $postedTestGroup;
|
||
if (!array_key_exists($postedTestGroup, $dbGroups)) {
|
||
$error = 'Unbekannte Datenbank-Konfiguration.';
|
||
} else {
|
||
$dbConfig = $getNested($current, $postedTestGroup);
|
||
if (!is_array($dbConfig)) {
|
||
$dbTestMessages[$postedTestGroup] = [
|
||
'type' => 'error',
|
||
'text' => 'Datenbank-Konfiguration ist unvollstaendig.',
|
||
];
|
||
} else {
|
||
$warning = $dbConfigWarning($dbConfig);
|
||
if ($warning !== null) {
|
||
$dbTestMessages[$postedTestGroup] = [
|
||
'type' => 'error',
|
||
'text' => $warning . ' ' . $describeDbConfig($dbConfig),
|
||
];
|
||
} else {
|
||
try {
|
||
$dbConfig['options'] = array_replace([
|
||
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
|
||
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
|
||
], (array)($dbConfig['options'] ?? []));
|
||
|
||
$testPdo = \App\Database::createFromArray($dbConfig);
|
||
$testPdo->query('SELECT 1')->fetchColumn();
|
||
$dbTestMessages[$postedTestGroup] = [
|
||
'type' => 'success',
|
||
'text' => 'Verbindung erfolgreich. ' . $describeDbConfig($dbConfig),
|
||
];
|
||
} catch (\Throwable $e) {
|
||
$dbTestMessages[$postedTestGroup] = [
|
||
'type' => 'error',
|
||
'text' => 'Verbindung fehlgeschlagen. ' . $describeDbConfig($dbConfig) . ' ' . $e->getMessage(),
|
||
];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
modules()->saveSettings($moduleName, $current);
|
||
if ($submittedSetupSection === 'access') {
|
||
$selectedUsers = is_array($_POST['auth_user_values'] ?? null) ? $_POST['auth_user_values'] : [];
|
||
$selectedGroups = is_array($_POST['auth_group_values'] ?? null) ? $_POST['auth_group_values'] : [];
|
||
$manualUserValues = preg_split('/[,\\n]+/', (string) ($_POST['auth_users'] ?? '')) ?: [];
|
||
$manualGroupValues = preg_split('/[,\\n]+/', (string) ($_POST['auth_groups'] ?? '')) ?: [];
|
||
modules()->saveAuth($moduleName, [
|
||
'required' => isset($_POST['auth_required']),
|
||
'users' => array_merge($selectedUsers, $manualUserValues),
|
||
'groups' => array_merge($selectedGroups, $manualGroupValues),
|
||
]);
|
||
}
|
||
if (modules()->hasFunction($moduleName, 'save_runtime_settings')) {
|
||
module_fn($moduleName, 'save_runtime_settings', $payload);
|
||
$current = modules()->settings($moduleName);
|
||
if ($runtimeSettingsEnabled) {
|
||
$runtimeSettings = module_fn($moduleName, 'runtime_settings');
|
||
if (is_array($runtimeSettings)) {
|
||
$current = array_replace_recursive($current, $runtimeSettings);
|
||
}
|
||
}
|
||
}
|
||
$refreshSchedulerState();
|
||
if ($submittedSetupSection === 'general' && array_key_exists('debug_enabled', $payload) && empty($payload['debug_enabled'])) {
|
||
module_debug_clear($moduleName);
|
||
}
|
||
$notice = 'Setup gespeichert.';
|
||
$module = modules()->get($moduleName) ?: $module;
|
||
$authConfig = is_array($module['auth'] ?? null) ? $module['auth'] : $authConfig;
|
||
}
|
||
}
|
||
|
||
$moduleStatusPanel = null;
|
||
if (modules()->hasFunction($moduleName, 'setup_status')) {
|
||
try {
|
||
$statusPanelPayload = module_fn($moduleName, 'setup_status');
|
||
if (is_array($statusPanelPayload)) {
|
||
$moduleStatusPanel = $statusPanelPayload;
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$moduleStatusPanel = [
|
||
'title' => 'Status',
|
||
'type' => 'error',
|
||
'text' => $e->getMessage(),
|
||
'stats' => [],
|
||
];
|
||
}
|
||
}
|
||
|
||
$activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
|
||
? $testGroup
|
||
: (array_key_first($dbGroups) ?? '');
|
||
$authConfig = is_array($module['auth'] ?? null) ? $module['auth'] : ['required' => false, 'users' => [], 'groups' => []];
|
||
$allowedUsers = is_array($authConfig['users'] ?? null) ? array_values(array_filter(array_map('strval', $authConfig['users']))) : [];
|
||
$allowedGroups = is_array($authConfig['groups'] ?? null) ? array_values(array_filter(array_map('strval', $authConfig['groups']))) : [];
|
||
$knownUsers = modules()->knownAuthUsers();
|
||
$knownGroups = modules()->knownAuthGroups();
|
||
$currentUser = auth_user();
|
||
if (is_array($currentUser) && trim((string)($currentUser['sub'] ?? '')) !== '') {
|
||
$currentSub = (string) $currentUser['sub'];
|
||
$hasCurrentUser = false;
|
||
foreach ($knownUsers as $knownUser) {
|
||
if ((string) ($knownUser['sub'] ?? '') === $currentSub) {
|
||
$hasCurrentUser = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!$hasCurrentUser) {
|
||
$knownUsers[] = [
|
||
'sub' => $currentSub,
|
||
'username' => (string) ($currentUser['username'] ?? ''),
|
||
'email' => (string) ($currentUser['email'] ?? ''),
|
||
'name' => (string) ($currentUser['name'] ?? ''),
|
||
'groups' => is_array($currentUser['groups'] ?? null) ? $currentUser['groups'] : [],
|
||
];
|
||
}
|
||
}
|
||
$knownGroups = array_values(array_unique(array_merge($knownGroups, auth_groups())));
|
||
sort($knownGroups, SORT_NATURAL | SORT_FLAG_CASE);
|
||
$knownUserValues = array_column($knownUsers, 'sub');
|
||
$manualUsers = array_values(array_filter($allowedUsers, fn (string $value): bool => !in_array($value, $knownUserValues, true)));
|
||
$manualGroups = array_values(array_filter($allowedGroups, fn (string $value): bool => !in_array($value, $knownGroups, true)));
|
||
$showCustomDbConfig = !empty($current['use_separate_db']) && !in_array(strtolower(trim((string) ($current['use_separate_db'] ?? ''))), ['0', 'false', 'off', 'standard'], true);
|
||
if (!in_array($currentSection, $allowedSetupSections, true)) {
|
||
$currentSection = 'general';
|
||
}
|
||
$sectionUrls = [
|
||
'general' => '/modules/setup/' . rawurlencode($moduleName) . '/general',
|
||
'database' => '/modules/setup/' . rawurlencode($moduleName) . '/database',
|
||
'access' => '/modules/setup/' . rawurlencode($moduleName) . '/access',
|
||
'cron' => '/modules/setup/' . rawurlencode($moduleName) . '/cron',
|
||
'custom' => '/modules/setup/' . rawurlencode($moduleName) . '/custom',
|
||
];
|
||
$sectionTitles = [
|
||
'general' => 'Allgemein',
|
||
'database' => 'Datenbank',
|
||
'access' => 'Zugriffsrechte',
|
||
'cron' => 'Cron Einstellungen',
|
||
'custom' => 'Custom Settings',
|
||
];
|
||
if ($currentSection === 'custom' && !$hasCustomSection) {
|
||
$currentSection = 'general';
|
||
}
|
||
$GLOBALS['layout_header_context'] = 'Setup / ' . ($sectionTitles[$currentSection] ?? 'Allgemein');
|
||
?>
|
||
<div class="module-shell"><div class="module-page-bg"><div class="module-page-stack">
|
||
<div class="submenu-box">
|
||
<div class="module-hero-top module-hero-top--compact">
|
||
<nav class="module-tabs" aria-label="Setup-Navigation">
|
||
<a class="module-button <?= $currentSection === 'general' ? 'module-button--tab-active' : 'module-button--tab' ?>" href="<?= e($sectionUrls['general']) ?>">Allgemein</a>
|
||
<?php if ($hasDatabaseSection): ?>
|
||
<a class="module-button <?= $currentSection === 'database' ? 'module-button--tab-active' : 'module-button--tab' ?>" href="<?= e($sectionUrls['database']) ?>">Datenbank</a>
|
||
<?php endif; ?>
|
||
<a class="module-button <?= $currentSection === 'access' ? 'module-button--tab-active' : 'module-button--tab' ?>" href="<?= e($sectionUrls['access']) ?>">Zugriffsrechte</a>
|
||
<a class="module-button <?= $currentSection === 'cron' ? 'module-button--tab-active' : 'module-button--tab' ?>" href="<?= e($sectionUrls['cron']) ?>">Cron Einstellungen</a>
|
||
<?php if ($hasCustomSection): ?>
|
||
<a class="module-button <?= $currentSection === 'custom' ? 'module-button--tab-active' : 'module-button--tab' ?>" href="<?= e($sectionUrls['custom']) ?>">Custom Settings</a>
|
||
<?php endif; ?>
|
||
</nav>
|
||
<div class="module-submenu-actions">
|
||
<a class="module-button module-button--secondary module-button--small" href="/modules">Nexus Übersicht</a>
|
||
<a class="module-button module-button--secondary module-button--small" href="/module/<?= e($moduleName) ?>">Zurück zum Modul</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<?php if ($error): ?>
|
||
<section class="section-box bg-red-900 border-l-4 border-red-500 text-red-100" role="alert">
|
||
<?= e($error) ?>
|
||
</section>
|
||
<?php elseif ($notice): ?>
|
||
<section class="section-box" style="border-color:var(--accent-2);">
|
||
<?= e($notice) ?>
|
||
</section>
|
||
<?php endif; ?>
|
||
|
||
<section class="section-box">
|
||
<div class="pill">Setup</div>
|
||
<h2 style="margin-top:.75rem;"><?= e($module['title']) ?> – <?= e($sectionTitles[$currentSection] ?? 'Allgemein') ?></h2>
|
||
<p class="muted">Trage die benötigten Informationen für das Modul ein.</p>
|
||
<form method="post" class="setup-form" style="margin-top:1rem;">
|
||
<input type="hidden" name="setup_section" value="<?= e($currentSection) ?>">
|
||
<datalist id="timezone-options">
|
||
<?php foreach ($timezoneOptions as $timezoneOption): ?>
|
||
<option value="<?= e((string) $timezoneOption['value']) ?>"><?= e((string) $timezoneOption['label']) ?></option>
|
||
<?php endforeach; ?>
|
||
</datalist>
|
||
<?php if ($currentSection === 'general' && $generalSetupFields !== []): ?>
|
||
<section class="setup-panel" id="setup-general">
|
||
<div class="setup-panel__head">
|
||
<div>
|
||
<span class="pill">Allgemein</span>
|
||
<h2>Moduleinstellungen</h2>
|
||
</div>
|
||
</div>
|
||
<div class="setup-grid">
|
||
<?php foreach ($generalSetupFields as $field): ?>
|
||
<?php $renderField($field); ?>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</section>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($currentSection === 'access'): ?>
|
||
<section class="setup-panel" id="setup-access">
|
||
<div class="setup-panel__head">
|
||
<div>
|
||
<span class="pill">Zugriffsrechte</span>
|
||
<h2>Zugriff verwalten</h2>
|
||
<p class="muted">Steuert, ob Login erforderlich ist und welche Benutzer oder Gruppen das Modul oeffnen duerfen.</p>
|
||
</div>
|
||
</div>
|
||
<div class="setup-grid">
|
||
<div class="setup-field muted">
|
||
<span>Login erforderlich</span>
|
||
<label style="display:flex; align-items:center; gap:10px;">
|
||
<input type="checkbox" name="auth_required" value="1" <?= !empty($authConfig['required']) ? 'checked' : '' ?>>
|
||
<span>Nur eingeloggte Nutzer duerfen dieses Modul oeffnen.</span>
|
||
</label>
|
||
</div>
|
||
<div class="setup-field muted">
|
||
<span>Erlaubte Benutzer</span>
|
||
<?php if ($knownUsers === []): ?>
|
||
<small class="muted">Noch keine bekannten Benutzer vorhanden. Nutzer erscheinen hier, sobald sie sich einmal angemeldet haben.</small>
|
||
<?php else: ?>
|
||
<div class="setup-auth-list">
|
||
<?php foreach ($knownUsers as $knownUser): ?>
|
||
<?php
|
||
$sub = (string) ($knownUser['sub'] ?? '');
|
||
$label = trim((string) ($knownUser['name'] ?? ''));
|
||
if ($label === '') {
|
||
$label = trim((string) ($knownUser['username'] ?? ''));
|
||
}
|
||
$email = trim((string) ($knownUser['email'] ?? ''));
|
||
$suffix = $email !== '' && $email !== $label ? ' (' . $email . ')' : '';
|
||
?>
|
||
<label style="display:flex; align-items:center; gap:10px;">
|
||
<input type="checkbox" name="auth_user_values[]" value="<?= e($sub) ?>" <?= in_array($sub, $allowedUsers, true) ? 'checked' : '' ?>>
|
||
<span><?= e(($label !== '' ? $label : $sub) . $suffix) ?></span>
|
||
</label>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<textarea name="auth_users" rows="3" placeholder="Weitere Keycloak-Sub, Benutzername oder E-Mail, je Zeile oder Komma"><?= e(implode("\n", $manualUsers)) ?></textarea>
|
||
</div>
|
||
<div class="setup-field muted">
|
||
<span>Erlaubte Gruppen</span>
|
||
<?php if ($knownGroups === []): ?>
|
||
<small class="muted">Noch keine bekannten Gruppen vorhanden.</small>
|
||
<?php else: ?>
|
||
<div class="setup-auth-list">
|
||
<?php foreach ($knownGroups as $knownGroup): ?>
|
||
<label style="display:flex; align-items:center; gap:10px;">
|
||
<input type="checkbox" name="auth_group_values[]" value="<?= e($knownGroup) ?>" <?= in_array($knownGroup, $allowedGroups, true) ? 'checked' : '' ?>>
|
||
<span><?= e($knownGroup) ?></span>
|
||
</label>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<textarea name="auth_groups" rows="3" placeholder="Weitere Gruppen, je Zeile oder Komma"><?= e(implode("\n", $manualGroups)) ?></textarea>
|
||
<small class="muted">Wenn Login aktiv ist und Benutzer sowie Gruppen leer bleiben, darf jeder eingeloggte Benutzer das Modul oeffnen.</small>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($currentSection === 'cron'): ?>
|
||
<section class="setup-panel" id="setup-cron">
|
||
<div class="setup-panel__head">
|
||
<div>
|
||
<span class="pill">Cron Einstellungen</span>
|
||
<h2>Scheduler und Zeitsteuerung</h2>
|
||
<p class="muted">Hier liegen die zeitbezogenen Modul-Einstellungen, Intervall-Tasks und Cron-Jobs.</p>
|
||
</div>
|
||
</div>
|
||
<?php if ($cronSetupFields !== []): ?>
|
||
<div class="setup-grid">
|
||
<?php foreach ($cronSetupFields as $field): ?>
|
||
<?php $renderField($field); ?>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php else: ?>
|
||
<p class="muted">Dieses Modul hat keine eigenen Zeitzonenfelder. Intervall-Tasks und Cron-Jobs koennen trotzdem weiter unten verwaltet werden.</p>
|
||
<?php endif; ?>
|
||
</section>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($currentSection === 'cron' && $intervalTaskStatuses !== []): ?>
|
||
<section class="setup-panel">
|
||
<div class="setup-panel__head">
|
||
<div>
|
||
<span class="pill">Automationen</span>
|
||
<h2>Intervall-Aufgaben</h2>
|
||
<p class="muted">Diese Aufgaben werden beim ersten gueltigen Modulaufruf nach Ablauf des Intervalls automatisch ausgefuehrt.</p>
|
||
</div>
|
||
</div>
|
||
<div class="setup-grid">
|
||
<?php foreach ($intervalTaskStatuses as $task): ?>
|
||
<?php $state = is_array($task['state'] ?? null) ? $task['state'] : []; ?>
|
||
<div class="setup-field muted">
|
||
<span><?= e((string) ($task['label'] ?? $task['name'] ?? 'Intervall-Aufgabe')) ?></span>
|
||
<input type="text" readonly value="<?= e(!empty($task['enabled']) ? 'Aktiv' : 'Deaktiviert') ?>">
|
||
<small class="muted">Intervall: <?= e(number_format((float) ($task['interval_hours'] ?? 0), 2, ',', '')) ?> Stunden</small>
|
||
<small class="muted">Letzter Start: <?= e($formatRunTimestamp((string) ($state['last_started_at'] ?? ''))) ?></small>
|
||
<small class="muted">Letzter Erfolg: <?= e($formatRunTimestamp((string) ($state['last_success_at'] ?? ''))) ?></small>
|
||
<small class="muted">Naechster Lauf: <?= e($formatRunTimestamp((string) ($task['next_due_at'] ?? ''))) ?></small>
|
||
<small class="muted">Status: <?= e((string) (($state['last_status'] ?? '') !== '' ? $state['last_status'] : '-')) ?></small>
|
||
<?php if (trim((string) ($state['last_message'] ?? '')) !== ''): ?>
|
||
<small class="muted">Meldung: <?= e((string) $state['last_message']) ?></small>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</section>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($currentSection === 'cron' && $cronTaskDefinitions !== []): ?>
|
||
<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>
|
||
<?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) ?>" data-job-label="<?= e((string) ($cronDefinition['label'] ?? $cronName)) ?>" data-job-callback="<?= e((string) ($cronDefinition['callback'] ?? '')) ?>">
|
||
<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" data-enabled <?= !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'] ?? ''), (string) ($task['timezone'] ?? 'UTC'))) ?></small>
|
||
<small class="muted">Letzter Erfolg: <?= e($formatRunTimestamp((string) ($task['state']['last_success_at'] ?? ''), (string) ($task['timezone'] ?? 'UTC'))) ?></small>
|
||
<small class="muted">Naechster Lauf lokal: <?= e(!empty($task['enabled']) ? (string) (($task['next_due_at_local'] ?? '') !== '' ? $task['next_due_at_local'] : '-') : '-') ?></small>
|
||
<small class="muted">Aktion: <?= e((string) (($cronDefinition['label'] ?? $cronName) !== '' ? ($cronDefinition['label'] ?? $cronName) : $cronName)) ?><?php if (trim((string) ($cronDefinition['callback'] ?? '')) !== ''): ?> (<?= e((string) $cronDefinition['callback']) ?>)<?php endif; ?></small>
|
||
<small class="muted">Status: <?= e((string) (($task['state']['last_status'] ?? '') !== '' ? $task['state']['last_status'] : '-')) ?></small>
|
||
<small class="muted">Meldung: <?= e((string) (($task['state']['last_message'] ?? '') !== '' ? $task['state']['last_message'] : '-')) ?></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 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; ?>
|
||
|
||
<?php if ($currentSection === 'database' && ($databaseSectionActions !== [] || is_array($moduleStatusPanel))): ?>
|
||
<section class="setup-panel">
|
||
<div class="setup-panel__head">
|
||
<div>
|
||
<span class="pill">Datenbank</span>
|
||
<h2>Datenbankaktionen und Tabellenstatus</h2>
|
||
<p class="muted">Tabellenbezogene Wartungsaktionen und der aktuelle Schema-Status werden hier gemeinsam angezeigt.</p>
|
||
</div>
|
||
</div>
|
||
<?php if ($databaseSectionActions !== []): ?>
|
||
<div class="setup-grid">
|
||
<?php foreach ($databaseSectionActions as $action): ?>
|
||
<?php
|
||
$actionName = trim((string)($action['name'] ?? ''));
|
||
$actionLabel = trim((string)($action['label'] ?? $actionName));
|
||
$actionHelp = trim((string)($action['help'] ?? ''));
|
||
if ($actionName === '' || $actionLabel === '') {
|
||
continue;
|
||
}
|
||
?>
|
||
<div class="setup-field muted">
|
||
<span><?= e($actionLabel) ?></span>
|
||
<?php if ($actionHelp !== ''): ?>
|
||
<small class="muted"><?= e($actionHelp) ?></small>
|
||
<?php endif; ?>
|
||
<div class="setup-actions" style="justify-content:flex-start; margin-top:12px;">
|
||
<button class="nav-link" type="submit" name="module_setup_action" value="<?= e($actionName) ?>" formnovalidate>
|
||
<?= e($actionLabel) ?>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if (is_array($moduleStatusPanel)): ?>
|
||
<div class="setup-panel__head" style="margin-top:20px;">
|
||
<div>
|
||
<span class="pill">Status</span>
|
||
<h3><?= e((string) $moduleStatusPanel['title']) ?></h3>
|
||
<p class="muted">Der Status wird beim Aufruf der Setup-Seite automatisch geprueft.</p>
|
||
</div>
|
||
</div>
|
||
<div class="setup-db-message setup-db-message--<?= e((string) $moduleStatusPanel['type']) ?>">
|
||
<?= e((string) $moduleStatusPanel['text']) ?>
|
||
</div>
|
||
<?php if (!empty($moduleStatusPanel['stats']) && is_array($moduleStatusPanel['stats'])): ?>
|
||
<div class="setup-grid" style="margin-top:16px;">
|
||
<?php foreach ($moduleStatusPanel['stats'] as $stat): ?>
|
||
<div class="setup-field muted">
|
||
<span><?= e((string) ($stat['label'] ?? '')) ?></span>
|
||
<div><?= e((string) ($stat['value'] ?? '')) ?></div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php endif; ?>
|
||
</section>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($currentSection === 'custom' && $customSectionActions !== []): ?>
|
||
<section class="setup-panel"<?= $customSetupFields === [] ? ' id="setup-custom"' : '' ?>>
|
||
<div class="setup-panel__head">
|
||
<div>
|
||
<span class="pill">Aktionen</span>
|
||
<h2>Modulaktionen</h2>
|
||
<p class="muted">Seltene Wartungsaktionen koennen direkt hier aus dem Setup ausgefuehrt werden.</p>
|
||
</div>
|
||
</div>
|
||
<div class="setup-grid">
|
||
<?php foreach ($customSectionActions as $action): ?>
|
||
<?php
|
||
$actionName = trim((string)($action['name'] ?? ''));
|
||
$actionLabel = trim((string)($action['label'] ?? $actionName));
|
||
$actionHelp = trim((string)($action['help'] ?? ''));
|
||
if ($actionName === '' || $actionLabel === '') {
|
||
continue;
|
||
}
|
||
?>
|
||
<div class="setup-field muted">
|
||
<span><?= e($actionLabel) ?></span>
|
||
<?php if ($actionHelp !== ''): ?>
|
||
<small class="muted"><?= e($actionHelp) ?></small>
|
||
<?php endif; ?>
|
||
<div class="setup-actions" style="justify-content:flex-start; margin-top:12px;">
|
||
<button class="nav-link" type="submit" name="module_setup_action" value="<?= e($actionName) ?>" formnovalidate>
|
||
<?= e($actionLabel) ?>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</section>
|
||
<?php endif; ?>
|
||
|
||
|
||
<?php if ($currentSection === 'database' && $dbGroups !== []): ?>
|
||
<section class="setup-panel"<?= $databaseSetupFields === [] ? ' id="setup-general"' : '' ?>>
|
||
<div class="setup-panel__head">
|
||
<div>
|
||
<span class="pill">Datenbank</span>
|
||
<h2>Verbindungen</h2>
|
||
<p class="muted">Standard nutzt die Nexus-Datenbank. Custom blendet eigene Verbindungsdaten und den DB-Test ein.</p>
|
||
</div>
|
||
</div>
|
||
<?php if ($databaseSetupFields !== []): ?>
|
||
<div class="setup-grid" style="margin-bottom:16px;">
|
||
<?php foreach ($databaseSetupFields as $field): ?>
|
||
<?php $renderField($field); ?>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php if (count($dbGroups) > 1): ?>
|
||
<div class="setup-tabs" aria-label="Datenbankbereiche" data-setup-db-root <?= $showCustomDbConfig ? '' : 'hidden' ?>>
|
||
<?php foreach ($dbGroups as $group => $label): ?>
|
||
<button
|
||
class="nav-link setup-tab<?= $group === $activeDbGroup ? ' is-active' : '' ?>"
|
||
type="button"
|
||
data-setup-tab-target="setup-db-<?= e($group) ?>"
|
||
aria-controls="setup-db-<?= e($group) ?>"
|
||
aria-selected="<?= $group === $activeDbGroup ? 'true' : 'false' ?>"
|
||
>
|
||
<?= e($label) ?>
|
||
</button>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<div class="setup-db-panels" data-setup-db-panels data-setup-db-custom-block <?= $showCustomDbConfig ? '' : 'hidden' ?>>
|
||
<?php foreach ($dbGroups as $group => $label): ?>
|
||
<?php
|
||
$panelConfig = $getNested($current, $group);
|
||
$panelConfig = is_array($panelConfig) ? $panelConfig : [];
|
||
$panelDefaults = $dbDefaultsByGroup[$group] ?? [];
|
||
$panelDefaults = is_array($panelDefaults) ? $panelDefaults : [];
|
||
$panelHint = $dbConfigHint($group, $panelConfig, $panelDefaults);
|
||
$defaultDescription = $describeDbDefaults($panelDefaults);
|
||
?>
|
||
<section class="setup-db-panel" id="setup-db-<?= e($group) ?>" <?= (count($dbGroups) === 1 || $group === $activeDbGroup) ? '' : 'hidden' ?>>
|
||
<div class="setup-panel__head">
|
||
<div>
|
||
<span class="pill"><?= e($label) ?></span>
|
||
<h3><?= e($label) ?> konfigurieren</h3>
|
||
<?php if ($defaultDescription !== ''): ?>
|
||
<p class="muted"><?= e($defaultDescription) ?></p>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div class="setup-db-actions">
|
||
<?php if ($panelDefaults !== []): ?>
|
||
<button class="nav-link" type="submit" name="reset_db" value="<?= e($group) ?>" formnovalidate>
|
||
Standardwerte laden
|
||
</button>
|
||
<?php endif; ?>
|
||
<button class="nav-link" type="submit" name="test_db" value="<?= e($group) ?>" formnovalidate>
|
||
Verbindung testen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<?php if (isset($dbTestMessages[$group])): ?>
|
||
<div class="setup-db-message setup-db-message--<?= e($dbTestMessages[$group]['type']) ?>">
|
||
<?= e($dbTestMessages[$group]['text']) ?>
|
||
</div>
|
||
<?php elseif ($panelHint !== null): ?>
|
||
<div class="setup-db-message setup-db-message--hint">
|
||
<?= e($panelHint) ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<div class="setup-grid">
|
||
<?php foreach (($fieldsByDbGroup[$group] ?? []) as $field): ?>
|
||
<?php $renderField($field); ?>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</section>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</section>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($currentSection === 'custom' && $customSetupFields !== []): ?>
|
||
<section class="setup-panel" id="setup-custom">
|
||
<div class="setup-panel__head">
|
||
<div>
|
||
<span class="pill">Custom Settings</span>
|
||
<h2>Modulspezifische Einstellungen</h2>
|
||
</div>
|
||
</div>
|
||
<div class="setup-grid">
|
||
<?php foreach ($customSetupFields as $field): ?>
|
||
<?php $renderField($field); ?>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</section>
|
||
<?php endif; ?>
|
||
|
||
<div class="setup-actions setup-actions--footer">
|
||
<button class="cta-button" type="submit">Setup speichern</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
<div class="scheduler-modal" data-scheduler-modal hidden>
|
||
<div class="scheduler-modal__backdrop" data-scheduler-close></div>
|
||
<div class="scheduler-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="scheduler-modal-title">
|
||
<div class="scheduler-modal__head">
|
||
<div>
|
||
<span class="pill">Scheduler</span>
|
||
<h3 id="scheduler-modal-title">Cron bearbeiten</h3>
|
||
</div>
|
||
<button class="nav-link" type="button" data-scheduler-close>Schliessen</button>
|
||
</div>
|
||
<div class="scheduler-modal__body">
|
||
<label class="setup-field muted">
|
||
<span>Aktiv</span>
|
||
<select data-modal-enabled>
|
||
<option value="1">Ja</option>
|
||
<option value="0">Nein</option>
|
||
</select>
|
||
</label>
|
||
<label class="setup-field muted">
|
||
<span>Zeitzone</span>
|
||
<input type="text" value="UTC" data-modal-timezone list="timezone-options" autocomplete="off">
|
||
</label>
|
||
|
||
<div class="scheduler-builder">
|
||
<div class="scheduler-builder__tabs">
|
||
<button class="scheduler-chip" type="button" data-builder-tab="hourly">Hourly</button>
|
||
<button class="scheduler-chip" type="button" data-builder-tab="daily">Daily</button>
|
||
<button class="scheduler-chip" type="button" data-builder-tab="weekly">Weekly</button>
|
||
<button class="scheduler-chip" type="button" data-builder-tab="monthly">Monthly</button>
|
||
<button class="scheduler-chip" type="button" data-builder-tab="custom">Custom</button>
|
||
</div>
|
||
|
||
<div class="scheduler-builder__panel" data-builder-panel="hourly" hidden>
|
||
<label class="setup-field muted">
|
||
<span>Alle x Stunden</span>
|
||
<input type="number" min="1" max="23" value="6" data-modal-interval-hours>
|
||
</label>
|
||
<div>
|
||
<span class="scheduler-builder__label">Minuten</span>
|
||
<div class="scheduler-builder__chips" data-minute-chips></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="scheduler-builder__panel" data-builder-panel="daily" hidden>
|
||
<div>
|
||
<span class="scheduler-builder__label">Stunden</span>
|
||
<div class="scheduler-builder__chips" data-hour-chips></div>
|
||
</div>
|
||
<div>
|
||
<span class="scheduler-builder__label">Minuten</span>
|
||
<div class="scheduler-builder__chips" data-minute-chips></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="scheduler-builder__panel" data-builder-panel="weekly" hidden>
|
||
<div>
|
||
<span class="scheduler-builder__label">Wochentage</span>
|
||
<div class="scheduler-builder__chips" data-weekday-chips></div>
|
||
</div>
|
||
<div>
|
||
<span class="scheduler-builder__label">Stunden</span>
|
||
<div class="scheduler-builder__chips" data-hour-chips></div>
|
||
</div>
|
||
<div>
|
||
<span class="scheduler-builder__label">Minuten</span>
|
||
<div class="scheduler-builder__chips" data-minute-chips></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="scheduler-builder__panel" data-builder-panel="monthly" hidden>
|
||
<label class="setup-field muted">
|
||
<span>Tag im Monat</span>
|
||
<input type="number" min="1" max="31" value="1" data-modal-month-day>
|
||
</label>
|
||
<div>
|
||
<span class="scheduler-builder__label">Stunden</span>
|
||
<div class="scheduler-builder__chips" data-hour-chips></div>
|
||
</div>
|
||
<div>
|
||
<span class="scheduler-builder__label">Minuten</span>
|
||
<div class="scheduler-builder__chips" data-minute-chips></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="scheduler-builder__panel" data-builder-panel="custom" hidden>
|
||
<label class="setup-field muted">
|
||
<span>Cron-Syntax</span>
|
||
<input type="text" value="" data-modal-expression>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="scheduler-preview">
|
||
<span>Vorschau</span>
|
||
<strong data-modal-summary>-</strong>
|
||
<code data-modal-code>-</code>
|
||
</div>
|
||
</div>
|
||
<div class="scheduler-modal__actions">
|
||
<button class="cta-button" type="button" data-scheduler-save>Uebernehmen</button>
|
||
<button class="nav-link" type="button" data-scheduler-close>Abbrechen</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
(() => {
|
||
const modeField = document.querySelector('[name="use_separate_db"]');
|
||
const panelsRoot = document.querySelector('[data-setup-db-panels]');
|
||
const customBlock = document.querySelector('[data-setup-db-custom-block]');
|
||
const tabsRoot = document.querySelector('[data-setup-db-root]');
|
||
const dbPanels = Array.from(document.querySelectorAll('.setup-db-panel'));
|
||
if (!modeField || !panelsRoot || !customBlock) return;
|
||
|
||
const isCustomMode = () => {
|
||
if (modeField instanceof HTMLInputElement && modeField.type === 'checkbox') {
|
||
return modeField.checked;
|
||
}
|
||
|
||
const value = String(modeField.value || '').trim().toLowerCase();
|
||
return !['', '0', 'false', 'off', 'standard'].includes(value);
|
||
};
|
||
|
||
const sync = () => {
|
||
const isCustom = isCustomMode();
|
||
if (tabsRoot) {
|
||
tabsRoot.hidden = !isCustom;
|
||
}
|
||
panelsRoot.hidden = !isCustom;
|
||
customBlock.hidden = !isCustom;
|
||
if (!isCustom) {
|
||
dbPanels.forEach((panel) => {
|
||
panel.hidden = true;
|
||
});
|
||
return;
|
||
}
|
||
const activeTab = tabsRoot
|
||
? (tabsRoot.querySelector('[data-setup-tab-target].is-active') || tabsRoot.querySelector('[data-setup-tab-target]'))
|
||
: null;
|
||
const activeTargetId = activeTab ? activeTab.dataset.setupTabTarget : '';
|
||
dbPanels.forEach((panel) => {
|
||
if (!tabsRoot || tabsRoot.hidden || tabsRoot.childElementCount <= 1) {
|
||
panel.hidden = false;
|
||
return;
|
||
}
|
||
panel.hidden = panel.id !== activeTargetId;
|
||
});
|
||
};
|
||
modeField.addEventListener('change', sync);
|
||
sync();
|
||
})();
|
||
|
||
(() => {
|
||
const jobs = document.querySelectorAll('[data-scheduler-job]');
|
||
const modal = document.querySelector('[data-scheduler-modal]');
|
||
if (!jobs.length || !modal) return;
|
||
|
||
const weekdayMap = {
|
||
'0': 'Sonntag',
|
||
'1': 'Montag',
|
||
'2': 'Dienstag',
|
||
'3': 'Mittwoch',
|
||
'4': 'Donnerstag',
|
||
'5': 'Freitag',
|
||
'6': 'Samstag',
|
||
};
|
||
|
||
const modalState = {
|
||
entry: null,
|
||
tab: 'daily',
|
||
hour: '18',
|
||
minute: '00',
|
||
weekday: '1',
|
||
};
|
||
const form = document.querySelector('form.setup-form');
|
||
|
||
const modalFields = {
|
||
enabled: modal.querySelector('[data-modal-enabled]'),
|
||
timezone: modal.querySelector('[data-modal-timezone]'),
|
||
intervalHours: modal.querySelector('[data-modal-interval-hours]'),
|
||
monthDay: modal.querySelector('[data-modal-month-day]'),
|
||
expression: modal.querySelector('[data-modal-expression]'),
|
||
summary: modal.querySelector('[data-modal-summary]'),
|
||
code: modal.querySelector('[data-modal-code]'),
|
||
saveButton: modal.querySelector('[data-scheduler-save]'),
|
||
tabs: Array.from(modal.querySelectorAll('[data-builder-tab]')),
|
||
panels: Array.from(modal.querySelectorAll('[data-builder-panel]')),
|
||
};
|
||
|
||
const minuteContainers = Array.from(modal.querySelectorAll('[data-minute-chips]'));
|
||
const hourContainers = Array.from(modal.querySelectorAll('[data-hour-chips]'));
|
||
const weekdayContainers = Array.from(modal.querySelectorAll('[data-weekday-chips]'));
|
||
|
||
const chipButton = (label, value, group) => {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = 'scheduler-chip';
|
||
button.textContent = label;
|
||
button.dataset.value = value;
|
||
button.dataset.group = group;
|
||
return button;
|
||
};
|
||
|
||
const populateChips = () => {
|
||
minuteContainers.forEach((container) => {
|
||
if (container.children.length) return;
|
||
['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'].forEach((minute) => {
|
||
container.appendChild(chipButton(minute, minute, 'minute'));
|
||
});
|
||
});
|
||
|
||
hourContainers.forEach((container) => {
|
||
if (container.children.length) return;
|
||
Array.from({ length: 24 }, (_, hour) => String(hour).padStart(2, '0')).forEach((hour) => {
|
||
container.appendChild(chipButton(hour, hour, 'hour'));
|
||
});
|
||
});
|
||
|
||
weekdayContainers.forEach((container) => {
|
||
if (container.children.length) return;
|
||
Object.entries(weekdayMap).forEach(([value, label]) => {
|
||
container.appendChild(chipButton(label.slice(0, 3), value, 'weekday'));
|
||
});
|
||
});
|
||
};
|
||
|
||
const formatTime = (hour, minute) => `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||
|
||
const buildExpression = (tab) => {
|
||
const minute = Math.max(0, Math.min(59, Number(modalState.minute || '0')));
|
||
const hour = Math.max(0, Math.min(23, Number(modalState.hour || '0')));
|
||
const intervalHours = Math.max(1, Math.min(23, Number(modalFields.intervalHours.value || 6)));
|
||
const monthDay = Math.max(1, Math.min(31, Number(modalFields.monthDay.value || 1)));
|
||
|
||
switch (tab) {
|
||
case 'hourly':
|
||
return `${minute} */${intervalHours} * * *`;
|
||
case 'weekly':
|
||
return `${minute} ${hour} * * ${modalState.weekday || '1'}`;
|
||
case 'monthly':
|
||
return `${minute} ${hour} ${monthDay} * *`;
|
||
case 'custom':
|
||
return String(modalFields.expression.value || '').trim();
|
||
case 'daily':
|
||
default:
|
||
return `${minute} ${hour} * * *`;
|
||
}
|
||
};
|
||
|
||
const buildSummary = (tab) => {
|
||
const enabled = modalFields.enabled.value === '1' ? 'Aktiv' : 'Inaktiv';
|
||
const timezone = modalFields.timezone.value.trim() || 'UTC';
|
||
const time = formatTime(modalState.hour, modalState.minute);
|
||
switch (tab) {
|
||
case 'hourly':
|
||
return `${enabled}, alle ${modalFields.intervalHours.value || '1'} Stunden um Minute ${modalState.minute}, ${timezone}`;
|
||
case 'weekly':
|
||
return `${enabled}, woechentlich ${weekdayMap[modalState.weekday || '1']} um ${time}, ${timezone}`;
|
||
case 'monthly':
|
||
return `${enabled}, monatlich am ${modalFields.monthDay.value || '1'}. um ${time}, ${timezone}`;
|
||
case 'custom':
|
||
return `${enabled}, benutzerdefinierte Cron-Syntax, ${timezone}`;
|
||
case 'daily':
|
||
default:
|
||
return `${enabled}, taeglich um ${time}, ${timezone}`;
|
||
}
|
||
};
|
||
|
||
const setActiveTab = (tab) => {
|
||
modalState.tab = tab;
|
||
modalFields.tabs.forEach((button) => {
|
||
button.classList.toggle('is-active', button.dataset.builderTab === tab);
|
||
});
|
||
modalFields.panels.forEach((panel) => {
|
||
panel.hidden = panel.dataset.builderPanel !== tab;
|
||
});
|
||
refreshPreview();
|
||
};
|
||
|
||
const refreshChipState = () => {
|
||
modal.querySelectorAll('.scheduler-chip[data-group="minute"]').forEach((button) => {
|
||
button.classList.toggle('is-active', button.dataset.value === modalState.minute);
|
||
});
|
||
modal.querySelectorAll('.scheduler-chip[data-group="hour"]').forEach((button) => {
|
||
button.classList.toggle('is-active', button.dataset.value === modalState.hour);
|
||
});
|
||
modal.querySelectorAll('.scheduler-chip[data-group="weekday"]').forEach((button) => {
|
||
button.classList.toggle('is-active', button.dataset.value === modalState.weekday);
|
||
});
|
||
};
|
||
|
||
const refreshPreview = () => {
|
||
if (modalState.tab !== 'custom') {
|
||
modalFields.expression.value = buildExpression(modalState.tab);
|
||
}
|
||
modalFields.summary.textContent = buildSummary(modalState.tab);
|
||
modalFields.code.textContent = buildExpression(modalState.tab) || '-';
|
||
refreshChipState();
|
||
};
|
||
|
||
const getEntryField = (entry, selector) => entry.querySelector(selector);
|
||
const findStatusNode = (entry, label) => Array.from(entry.querySelectorAll('small')).find((node) => node.textContent.includes(label));
|
||
const setStatusText = (entry, label, value) => {
|
||
const node = findStatusNode(entry, label);
|
||
if (node) {
|
||
node.textContent = `${label}: ${value}`;
|
||
}
|
||
};
|
||
|
||
const applyStatusUpdates = (statuses) => {
|
||
if (!Array.isArray(statuses)) return;
|
||
const statusMap = new Map();
|
||
statuses.forEach((status) => {
|
||
statusMap.set(`${status.job_name}#${status.entry_index}`, status);
|
||
});
|
||
|
||
jobs.forEach((job) => {
|
||
const jobName = job.dataset.jobName || '';
|
||
job.querySelectorAll('[data-scheduler-entry]').forEach((entry) => {
|
||
const key = `${jobName}#${entry.dataset.entryIndex || '0'}`;
|
||
const status = statusMap.get(key);
|
||
if (!status) return;
|
||
const enabledNode = getEntryField(entry, '[data-enabled]');
|
||
if (enabledNode) {
|
||
enabledNode.checked = !!status.enabled;
|
||
}
|
||
setStatusText(entry, 'Letzter Start', status.last_started_at_local || status.state?.last_started_at || '-');
|
||
setStatusText(entry, 'Letzter Erfolg', status.last_success_at_local || status.state?.last_success_at || '-');
|
||
setStatusText(entry, 'Naechster Lauf lokal', status.enabled ? (status.next_due_at_local || '-') : '-');
|
||
setStatusText(entry, 'Status', status.state?.last_status || '-');
|
||
setStatusText(entry, 'Meldung', status.state?.last_message || '-');
|
||
updateEntrySummary(entry);
|
||
});
|
||
});
|
||
};
|
||
|
||
const collectSchedulerPayload = () => {
|
||
const payload = new FormData();
|
||
payload.append('scheduler_autosave', '1');
|
||
jobs.forEach((job) => {
|
||
job.querySelectorAll('[data-scheduler-entry]').forEach((entry) => {
|
||
entry.querySelectorAll('[name]').forEach((field) => {
|
||
if (field instanceof HTMLInputElement && field.type === 'checkbox') {
|
||
if (field.checked) {
|
||
payload.append(field.name, field.value || '1');
|
||
}
|
||
return;
|
||
}
|
||
payload.append(field.name, field.value);
|
||
});
|
||
});
|
||
});
|
||
try {
|
||
console.groupCollapsed('[scheduler] collect payload');
|
||
for (const pair of payload.entries()) {
|
||
console.log(pair[0], pair[1]);
|
||
}
|
||
console.groupEnd();
|
||
} catch (error) {
|
||
console.warn('[scheduler] payload debug failed', error);
|
||
}
|
||
return payload;
|
||
};
|
||
|
||
const collectSchedulerTestPayload = (jobName, entryIndex) => {
|
||
const payload = collectSchedulerPayload();
|
||
payload.delete('scheduler_autosave');
|
||
payload.append('scheduler_test', '1');
|
||
payload.append('scheduler_job_name', jobName);
|
||
payload.append('scheduler_entry_index', String(entryIndex));
|
||
return payload;
|
||
};
|
||
|
||
const persistScheduler = async () => {
|
||
if (!form) return true;
|
||
const url = form.action || window.location.href;
|
||
console.log('[scheduler] persist start', {
|
||
url,
|
||
method: 'POST',
|
||
hasForm: !!form,
|
||
});
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
body: collectSchedulerPayload(),
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
'Accept': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
});
|
||
console.log('[scheduler] persist response meta', {
|
||
ok: response.ok,
|
||
status: response.status,
|
||
redirected: response.redirected,
|
||
contentType: response.headers.get('content-type'),
|
||
url: response.url,
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`Scheduler konnte nicht gespeichert werden (${response.status}).`);
|
||
}
|
||
const raw = await response.text();
|
||
console.log('[scheduler] persist raw response', raw);
|
||
let data = null;
|
||
try {
|
||
data = JSON.parse(raw);
|
||
} catch (error) {
|
||
console.error('[scheduler] persist json parse failed', error);
|
||
throw new Error(`Scheduler-Antwort war kein JSON: ${raw.slice(0, 160)}`);
|
||
}
|
||
console.log('[scheduler] persist parsed response', data);
|
||
if (!data || data.ok !== true) {
|
||
throw new Error(data?.message || 'Scheduler konnte nicht gespeichert werden.');
|
||
}
|
||
applyStatusUpdates(data.statuses || []);
|
||
console.log('[scheduler] persist success');
|
||
return true;
|
||
};
|
||
|
||
const testScheduler = async (jobName, entryIndex) => {
|
||
if (!form) return null;
|
||
const url = form.action || window.location.href;
|
||
console.log('[scheduler] test start', { jobName, entryIndex, url });
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
body: collectSchedulerTestPayload(jobName, entryIndex),
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
'Accept': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
});
|
||
const raw = await response.text();
|
||
console.log('[scheduler] test raw response', raw);
|
||
let data = null;
|
||
try {
|
||
data = JSON.parse(raw);
|
||
} catch (error) {
|
||
console.error('[scheduler] test json parse failed', error);
|
||
throw new Error(`Cron-Test Antwort war kein JSON: ${raw.slice(0, 160)}`);
|
||
}
|
||
if (!response.ok || !data || data.ok !== true) {
|
||
throw new Error(data?.message || `Cron-Test fehlgeschlagen (${response.status}).`);
|
||
}
|
||
applyStatusUpdates(data.statuses || []);
|
||
return data;
|
||
};
|
||
|
||
const updateEntrySummary = (entry) => {
|
||
const enabled = getEntryField(entry, '[data-enabled]')?.checked ?? false;
|
||
const expression = getEntryField(entry, '[data-cron-expression]')?.value || '';
|
||
const timezone = (getEntryField(entry, '[data-cron-timezone]')?.value || 'UTC').trim() || 'UTC';
|
||
const builderMode = getEntryField(entry, '[data-cron-builder-mode]')?.value || 'builder';
|
||
const builderKind = getEntryField(entry, '[data-cron-builder-kind]')?.value || 'daily';
|
||
const time = getEntryField(entry, '[data-cron-builder-time]')?.value || '18:00';
|
||
const weekday = getEntryField(entry, '[data-cron-builder-weekday]')?.value || '1';
|
||
const monthDay = getEntryField(entry, '[data-cron-builder-month-day]')?.value || '1';
|
||
const intervalHours = getEntryField(entry, '[data-cron-builder-interval-hours]')?.value || '6';
|
||
|
||
let summary = enabled ? 'Aktiv' : 'Inaktiv';
|
||
if (builderMode === 'manual') {
|
||
summary += `, Custom, ${timezone}`;
|
||
} else if (builderKind === 'every_x_hours') {
|
||
summary += `, alle ${intervalHours} Stunden, ${timezone}`;
|
||
} else if (builderKind === 'weekly') {
|
||
summary += `, ${weekdayMap[weekday] || weekday} ${time}, ${timezone}`;
|
||
} else if (builderKind === 'monthly_day') {
|
||
summary += `, monatlich am ${monthDay}. ${time}, ${timezone}`;
|
||
} else {
|
||
summary += `, taeglich ${time}, ${timezone}`;
|
||
}
|
||
|
||
let summaryNode = entry.querySelector('[data-entry-summary]');
|
||
if (!summaryNode) {
|
||
summaryNode = document.createElement('div');
|
||
summaryNode.className = 'scheduler-entry__summary';
|
||
summaryNode.dataset.entrySummary = '';
|
||
entry.prepend(summaryNode);
|
||
}
|
||
|
||
let statusNode = entry.querySelector('[data-entry-status]');
|
||
if (!statusNode) {
|
||
statusNode = document.createElement('div');
|
||
statusNode.className = 'scheduler-entry__status';
|
||
statusNode.dataset.entryStatus = '';
|
||
entry.appendChild(statusNode);
|
||
}
|
||
|
||
const lastStart = findStatusNode(entry, 'Letzter Start');
|
||
const lastSuccess = findStatusNode(entry, 'Letzter Erfolg');
|
||
const nextLocal = findStatusNode(entry, 'Naechster Lauf lokal');
|
||
const status = findStatusNode(entry, 'Status');
|
||
const parseError = findStatusNode(entry, 'Cron-Fehler');
|
||
|
||
summaryNode.innerHTML = `
|
||
<strong>${summary}</strong>
|
||
<span>${expression || '-'}</span>
|
||
`;
|
||
|
||
statusNode.innerHTML = `
|
||
<small class="muted">${lastStart ? lastStart.textContent : 'Letzter Start: -'}</small>
|
||
<small class="muted">${lastSuccess ? lastSuccess.textContent : 'Letzter Erfolg: -'}</small>
|
||
<small class="muted">${nextLocal ? nextLocal.textContent : 'Naechster Lauf lokal: -'}</small>
|
||
<small class="muted">${status ? status.textContent : 'Status: -'}</small>
|
||
<small class="muted">${findStatusNode(entry, 'Meldung') ? findStatusNode(entry, 'Meldung').textContent : 'Meldung: -'}</small>
|
||
${parseError ? `<small class="muted scheduler-entry__error">${parseError.textContent}</small>` : ''}
|
||
`;
|
||
};
|
||
|
||
const hideEditorNodes = (entry) => {
|
||
[
|
||
'label',
|
||
'[data-cron-expression]',
|
||
'[data-cron-timezone]',
|
||
'[data-cron-builder-mode]',
|
||
'[data-cron-builder-fields]',
|
||
'[data-remove-scheduler-entry]',
|
||
].forEach((selector) => {
|
||
const node = selector === 'label' ? entry.querySelector(':scope > label') : entry.querySelector(selector);
|
||
if (node) {
|
||
node.classList.add('scheduler-entry__editor');
|
||
node.hidden = true;
|
||
}
|
||
});
|
||
|
||
Array.from(entry.querySelectorAll('small')).forEach((node) => {
|
||
node.classList.add('scheduler-entry__editor');
|
||
node.hidden = true;
|
||
});
|
||
};
|
||
|
||
const openModal = (entry) => {
|
||
modalState.entry = entry;
|
||
const mode = getEntryField(entry, '[data-cron-builder-mode]')?.value || 'builder';
|
||
const kind = getEntryField(entry, '[data-cron-builder-kind]')?.value || 'daily';
|
||
const time = getEntryField(entry, '[data-cron-builder-time]')?.value || '18:00';
|
||
const [hour = '18', minute = '00'] = time.split(':');
|
||
|
||
modalFields.enabled.value = getEntryField(entry, '[data-enabled]')?.checked ? '1' : '0';
|
||
modalFields.timezone.value = getEntryField(entry, '[data-cron-timezone]')?.value || 'UTC';
|
||
modalFields.intervalHours.value = getEntryField(entry, '[data-cron-builder-interval-hours]')?.value || '6';
|
||
modalFields.monthDay.value = getEntryField(entry, '[data-cron-builder-month-day]')?.value || '1';
|
||
modalFields.expression.value = getEntryField(entry, '[data-cron-expression]')?.value || '';
|
||
modalState.hour = String(hour).padStart(2, '0');
|
||
modalState.minute = String(minute).padStart(2, '0');
|
||
modalState.weekday = getEntryField(entry, '[data-cron-builder-weekday]')?.value || '1';
|
||
|
||
setActiveTab(mode === 'manual'
|
||
? 'custom'
|
||
: (kind === 'every_x_hours' ? 'hourly' : (kind === 'weekly' ? 'weekly' : (kind === 'monthly_day' ? 'monthly' : 'daily'))));
|
||
|
||
modal.hidden = false;
|
||
document.body.classList.add('scheduler-modal-open');
|
||
};
|
||
|
||
const closeModal = () => {
|
||
modal.hidden = true;
|
||
modalState.entry = null;
|
||
document.body.classList.remove('scheduler-modal-open');
|
||
};
|
||
|
||
const saveModal = async () => {
|
||
const entry = modalState.entry;
|
||
if (!entry) return;
|
||
console.log('[scheduler] modal save click', {
|
||
tab: modalState.tab,
|
||
entryIndex: entry.dataset.entryIndex || null,
|
||
enabled: modalFields.enabled.value,
|
||
timezone: modalFields.timezone.value,
|
||
});
|
||
|
||
const modeNode = getEntryField(entry, '[data-cron-builder-mode]');
|
||
const kindNode = getEntryField(entry, '[data-cron-builder-kind]');
|
||
const timeNode = getEntryField(entry, '[data-cron-builder-time]');
|
||
const weekdayNode = getEntryField(entry, '[data-cron-builder-weekday]');
|
||
const monthDayNode = getEntryField(entry, '[data-cron-builder-month-day]');
|
||
const intervalHoursNode = getEntryField(entry, '[data-cron-builder-interval-hours]');
|
||
const intervalDaysNode = getEntryField(entry, '[data-cron-builder-interval-days]');
|
||
const enabledNode = getEntryField(entry, '[data-enabled]');
|
||
const expressionNode = getEntryField(entry, '[data-cron-expression]');
|
||
const timezoneNode = getEntryField(entry, '[data-cron-timezone]');
|
||
|
||
if (!modeNode || !kindNode || !timeNode || !weekdayNode || !monthDayNode || !intervalHoursNode || !intervalDaysNode || !enabledNode || !expressionNode || !timezoneNode) {
|
||
console.error('[scheduler] modal save aborted: missing entry fields');
|
||
return;
|
||
}
|
||
|
||
enabledNode.checked = modalFields.enabled.value === '1';
|
||
timezoneNode.value = modalFields.timezone.value.trim() || 'UTC';
|
||
timeNode.value = formatTime(modalState.hour, modalState.minute);
|
||
weekdayNode.value = modalState.weekday;
|
||
monthDayNode.value = String(Math.max(1, Math.min(31, Number(modalFields.monthDay.value || 1))));
|
||
intervalHoursNode.value = String(Math.max(1, Math.min(23, Number(modalFields.intervalHours.value || 6))));
|
||
intervalDaysNode.value = intervalDaysNode.value || '2';
|
||
|
||
if (modalState.tab === 'custom') {
|
||
modeNode.value = 'manual';
|
||
expressionNode.value = modalFields.expression.value.trim();
|
||
} else {
|
||
modeNode.value = 'builder';
|
||
kindNode.value = modalState.tab === 'hourly'
|
||
? 'every_x_hours'
|
||
: (modalState.tab === 'weekly' ? 'weekly' : (modalState.tab === 'monthly' ? 'monthly_day' : 'daily'));
|
||
expressionNode.value = buildExpression(modalState.tab);
|
||
}
|
||
|
||
updateEntrySummary(entry);
|
||
const originalLabel = modalFields.saveButton?.textContent || 'Uebernehmen';
|
||
if (modalFields.saveButton) {
|
||
modalFields.saveButton.disabled = true;
|
||
modalFields.saveButton.textContent = 'Speichert...';
|
||
}
|
||
try {
|
||
await persistScheduler();
|
||
console.log('[scheduler] modal save complete, closing modal');
|
||
closeModal();
|
||
} catch (error) {
|
||
console.error('[scheduler] modal save failed', error);
|
||
alert(error instanceof Error ? error.message : 'Scheduler konnte nicht gespeichert werden.');
|
||
} finally {
|
||
if (modalFields.saveButton) {
|
||
modalFields.saveButton.disabled = false;
|
||
modalFields.saveButton.textContent = originalLabel;
|
||
}
|
||
}
|
||
};
|
||
|
||
const createEntry = (job, values = {}) => {
|
||
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="hidden" value="" data-cron-expression>
|
||
<input type="hidden" value="" data-cron-timezone>
|
||
<input type="hidden" value="builder" data-cron-builder-mode>
|
||
<div data-cron-builder-fields hidden>
|
||
<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="hidden" value="18:00" data-cron-builder-time>
|
||
<input type="hidden" value="2" data-cron-builder-interval-days>
|
||
<select data-cron-builder-weekday>
|
||
<option value="0">Sonntag</option>
|
||
<option value="1">Montag</option>
|
||
<option value="2">Dienstag</option>
|
||
<option value="3">Mittwoch</option>
|
||
<option value="4">Donnerstag</option>
|
||
<option value="5">Freitag</option>
|
||
<option value="6">Samstag</option>
|
||
</select>
|
||
<input type="hidden" value="1" data-cron-builder-month-day>
|
||
<input type="hidden" 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 lokal: -</small>
|
||
<small class="muted">Aktion: ${(job.dataset.jobLabel || job.dataset.jobName || 'Cron-Job')}${job.dataset.jobCallback ? ` (${job.dataset.jobCallback})` : ''}</small>
|
||
<small class="muted">Status: -</small>
|
||
<small class="muted">Meldung: -</small>
|
||
`;
|
||
|
||
getEntryField(entry, '[data-enabled]').checked = Boolean(values.enabled);
|
||
getEntryField(entry, '[data-cron-expression]').value = values.cron_expression || '0 18 * * *';
|
||
getEntryField(entry, '[data-cron-timezone]').value = values.timezone || 'UTC';
|
||
getEntryField(entry, '[data-cron-builder-mode]').value = values.builderMode || 'builder';
|
||
getEntryField(entry, '[data-cron-builder-kind]').value = values.builderKind || 'daily';
|
||
getEntryField(entry, '[data-cron-builder-time]').value = values.time || '18:00';
|
||
getEntryField(entry, '[data-cron-builder-interval-days]').value = values.intervalDays || '2';
|
||
getEntryField(entry, '[data-cron-builder-weekday]').value = values.weekday || '1';
|
||
getEntryField(entry, '[data-cron-builder-month-day]').value = values.monthDay || '1';
|
||
getEntryField(entry, '[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) => {
|
||
entry.dataset.entryIndex = String(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 = (job, entry) => {
|
||
hideEditorNodes(entry);
|
||
updateEntrySummary(entry);
|
||
|
||
let actions = entry.querySelector('[data-entry-actions]');
|
||
if (!actions) {
|
||
actions = document.createElement('div');
|
||
actions.className = 'scheduler-entry__actions';
|
||
actions.dataset.entryActions = '';
|
||
actions.innerHTML = `
|
||
<button class="nav-link" type="button" data-entry-edit>Bearbeiten</button>
|
||
<button class="nav-link" type="button" data-entry-test>Cron testen</button>
|
||
${job.dataset.jobMode === 'multi' ? '<button class="nav-link" type="button" data-remove-scheduler-entry>Entfernen</button>' : ''}
|
||
`;
|
||
entry.appendChild(actions);
|
||
}
|
||
|
||
actions.querySelector('[data-entry-edit]')?.addEventListener('click', () => openModal(entry));
|
||
actions.querySelector('[data-entry-test]')?.addEventListener('click', async () => {
|
||
const testButton = actions.querySelector('[data-entry-test]');
|
||
const originalText = testButton?.textContent || 'Cron testen';
|
||
const jobName = job.dataset.jobName || '';
|
||
const entryIndex = Number(entry.dataset.entryIndex || '0');
|
||
if (testButton) {
|
||
testButton.disabled = true;
|
||
testButton.textContent = 'Teste...';
|
||
}
|
||
try {
|
||
const result = await testScheduler(jobName, entryIndex);
|
||
alert(result?.message || 'Cron-Test erfolgreich.');
|
||
} catch (error) {
|
||
alert(error instanceof Error ? error.message : 'Cron-Test fehlgeschlagen.');
|
||
} finally {
|
||
if (testButton) {
|
||
testButton.disabled = false;
|
||
testButton.textContent = originalText;
|
||
}
|
||
}
|
||
});
|
||
actions.querySelector('[data-remove-scheduler-entry]')?.addEventListener('click', async () => {
|
||
entry.remove();
|
||
reindexJob(job);
|
||
try {
|
||
await persistScheduler();
|
||
} catch (error) {
|
||
alert(error instanceof Error ? error.message : 'Scheduler konnte nicht gespeichert werden.');
|
||
}
|
||
});
|
||
};
|
||
|
||
populateChips();
|
||
|
||
modalFields.tabs.forEach((button) => {
|
||
button.addEventListener('click', () => setActiveTab(button.dataset.builderTab || 'daily'));
|
||
});
|
||
|
||
modal.querySelectorAll('.scheduler-chip[data-group]').forEach((button) => {
|
||
button.addEventListener('click', () => {
|
||
const group = button.dataset.group;
|
||
if (group === 'hour') {
|
||
modalState.hour = button.dataset.value || '18';
|
||
} else if (group === 'minute') {
|
||
modalState.minute = button.dataset.value || '00';
|
||
} else if (group === 'weekday') {
|
||
modalState.weekday = button.dataset.value || '1';
|
||
}
|
||
refreshPreview();
|
||
});
|
||
});
|
||
|
||
[modalFields.enabled, modalFields.timezone, modalFields.intervalHours, modalFields.monthDay, modalFields.expression].forEach((node) => {
|
||
node?.addEventListener('input', refreshPreview);
|
||
node?.addEventListener('change', refreshPreview);
|
||
});
|
||
|
||
modal.querySelectorAll('[data-scheduler-close]').forEach((button) => {
|
||
button.addEventListener('click', closeModal);
|
||
});
|
||
modal.querySelector('[data-scheduler-save]')?.addEventListener('click', saveModal);
|
||
|
||
jobs.forEach((job) => {
|
||
job.querySelectorAll('[data-scheduler-entry]').forEach((entry) => bindEntry(job, entry));
|
||
reindexJob(job);
|
||
|
||
const addButton = job.querySelector('[data-add-scheduler-entry]');
|
||
addButton?.addEventListener('click', () => {
|
||
const container = job.querySelector('[data-scheduler-entries]');
|
||
if (!container) return;
|
||
const entry = createEntry(job, { timezone: container.querySelector('[data-cron-timezone]')?.value || 'UTC' });
|
||
container.appendChild(entry);
|
||
bindEntry(job, entry);
|
||
reindexJob(job);
|
||
openModal(entry);
|
||
});
|
||
});
|
||
})();
|
||
</script>
|
||
<style>
|
||
.scheduler-entries {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.scheduler-entry {
|
||
display: grid;
|
||
gap: 12px;
|
||
padding: 14px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
background: color-mix(in srgb, var(--surface) 88%, white);
|
||
}
|
||
|
||
.scheduler-entry__summary {
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
|
||
.scheduler-entry__summary strong {
|
||
color: var(--text);
|
||
}
|
||
|
||
.scheduler-entry__summary span {
|
||
color: var(--muted);
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.scheduler-entry__status {
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
|
||
.scheduler-entry__error {
|
||
color: #b42318;
|
||
}
|
||
|
||
.scheduler-entry__actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.scheduler-modal[hidden] {
|
||
display: none;
|
||
}
|
||
|
||
.scheduler-modal {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 90;
|
||
}
|
||
|
||
.scheduler-modal__backdrop {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(255, 255, 255, 0.38);
|
||
backdrop-filter: blur(1px);
|
||
}
|
||
|
||
.scheduler-modal__dialog {
|
||
position: relative;
|
||
width: min(920px, calc(100vw - 32px));
|
||
margin: 40px auto;
|
||
max-height: calc(100vh - 80px);
|
||
overflow: auto;
|
||
padding: 22px;
|
||
border-radius: 18px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--line);
|
||
box-shadow: 0 30px 70px rgba(1, 22, 32, 0.24);
|
||
}
|
||
|
||
.scheduler-modal__head,
|
||
.scheduler-modal__actions {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.scheduler-modal__body {
|
||
display: grid;
|
||
gap: 18px;
|
||
margin: 18px 0;
|
||
}
|
||
|
||
.scheduler-builder {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
|
||
.scheduler-builder__tabs,
|
||
.scheduler-builder__chips {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.scheduler-builder__label {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
color: var(--muted);
|
||
font-size: 0.92rem;
|
||
}
|
||
|
||
.scheduler-chip {
|
||
appearance: none;
|
||
border: 1px solid var(--line);
|
||
background: #fff;
|
||
color: var(--text);
|
||
border-radius: 10px;
|
||
padding: 8px 12px;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
}
|
||
|
||
.scheduler-chip.is-active {
|
||
background: #17172b;
|
||
color: #fff;
|
||
border-color: #17172b;
|
||
}
|
||
|
||
.scheduler-preview {
|
||
display: grid;
|
||
gap: 6px;
|
||
padding: 14px;
|
||
border-radius: 12px;
|
||
background: color-mix(in srgb, var(--surface-strong) 86%, white);
|
||
border: 1px solid var(--line);
|
||
}
|
||
|
||
.scheduler-preview code {
|
||
display: inline-block;
|
||
width: fit-content;
|
||
padding: 4px 8px;
|
||
border-radius: 8px;
|
||
background: #eef2ff;
|
||
}
|
||
|
||
.scheduler-modal-open {
|
||
overflow: hidden;
|
||
}
|
||
</style>
|
||
</div></div></div>
|