Files
nexus/partials/landingpages/modules/setup.php
Lars Gebhardt-Kusche 4d9d9f3480
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 6s
yxcxyc
2026-05-06 00:21:53 +02:00

2555 lines
115 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
$moduleName = (string)($_GET['module'] ?? '');
$module = modules()->get($moduleName);
$error = null;
$notice = null;
$testGroup = null;
$dbTestMessages = [];
require_admin();
if (!$module) {
http_response_code(404);
echo '<div class="card">Modul nicht gefunden.</div>';
return;
}
$fields = (array)($module['setup']['fields'] ?? []);
$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;
}
$isFxRatesSetup = $moduleName === 'fx-rates';
$current = modules()->settings($moduleName);
$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')
: [];
$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);
$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 = [];
$cronSetupFields = [];
$customSetupFields = [];
foreach ($generalFields as $field) {
$fieldName = (string)($field['name'] ?? '');
if ($fieldName === 'debug_enabled') {
$generalSetupFields[] = $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'] ?? '');
$postKey = str_replace('.', '_', $name);
$value = '';
if ($name === 'kea_auto_init') {
$value = !empty($current[$name]) ? '1' : '0';
} elseif (str_contains($name, '.')) {
$value = (string)($getNested($current, $name) ?? '');
} else {
$value = (string)($current[$name] ?? '');
}
?>
<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 === '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
};
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';
$payload = [];
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;
}
$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);
$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 ($isFxRatesSetup && modules()->hasFunction($moduleName, 'save_runtime_settings')) {
module_fn($moduleName, 'save_runtime_settings', $payload);
$current = modules()->settings($moduleName);
}
$refreshSchedulerState();
if (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;
$activeSetupSection = trim((string) ($_POST['active_setup_section'] ?? 'setup-section-general'));
if (!in_array($activeSetupSection, ['setup-section-general', 'setup-section-access', 'setup-section-cron', 'setup-section-custom'], true)) {
$activeSetupSection = 'setup-section-general';
}
$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)));
$hasCronSection = $cronSetupFields !== [] || $intervalTaskStatuses !== [] || $cronTaskDefinitions !== [];
$hasCustomSection = $customSetupFields !== [] || $setupActions !== [] || $isFxRatesSetup;
if ($activeSetupSection === 'setup-section-custom' && !$hasCustomSection) {
$activeSetupSection = 'setup-section-general';
}
?>
<div class="setup-shell">
<div class="pill">Setup</div>
<h1 class="setup-title"><?= e($module['title']) ?> Einrichtung</h1>
<p class="muted">Trage die benötigten Informationen für das Modul ein.</p>
<?php if ($error): ?>
<div class="bg-red-900 border-l-4 border-red-500 text-red-100 p-4 mb-6" role="alert">
<?= e($error) ?>
</div>
<?php elseif ($notice): ?>
<div class="setup-notice">
<?= e($notice) ?>
</div>
<?php endif; ?>
<div class="setup-shell__layout">
<aside class="setup-shell__sidebar setup-shell__sidebar--left" aria-label="Setup-Bereiche">
<div class="setup-nav">
<span class="pill">Bereiche</span>
<a class="nav-link setup-nav__link" href="#setup-general">Allgemein</a>
<a class="nav-link setup-nav__link" href="#setup-access">Zugriffsrechte</a>
<a class="nav-link setup-nav__link" href="#setup-cron">Cron Einstellungen</a>
<?php if ($hasCustomSection): ?>
<a class="nav-link setup-nav__link" href="#setup-custom">Custom Settings</a>
<?php endif; ?>
</div>
</aside>
<div class="setup-shell__content">
<form method="post" class="setup-form">
<input type="hidden" name="active_setup_section" value="<?= e($activeSetupSection) ?>">
<datalist id="timezone-options">
<?php foreach ($timezoneOptions as $timezoneOption): ?>
<option value="<?= e((string) $timezoneOption['value']) ?>"><?= e((string) $timezoneOption['label']) ?></option>
<?php endforeach; ?>
</datalist>
<?php if ($isFxRatesSetup): ?>
<?php
$fxCatalog = is_array($current['currency_catalog'] ?? null) ? $current['currency_catalog'] : [];
$fxCatalogOptions = [];
foreach ($fxCatalog as $currency) {
if (!is_array($currency)) {
continue;
}
$code = strtoupper(trim((string) ($currency['code'] ?? '')));
$name = trim((string) ($currency['name'] ?? ''));
if ($code === '' || $name === '') {
continue;
}
$fxCatalogOptions[$code] = $name;
}
ksort($fxCatalogOptions);
$fxCatalogAvailable = $fxCatalogOptions !== [];
$fxPreferred = is_array($current['preferred_currencies'] ?? null) ? $current['preferred_currencies'] : [];
$fxUseSeparateDb = !empty($current['use_separate_db']);
$fxCatalogJson = json_encode(array_map(
static fn (string $name, string $code): array => ['code' => $code, 'name' => $name],
array_values($fxCatalogOptions),
array_keys($fxCatalogOptions)
), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
?>
<section class="setup-panel" id="setup-custom">
<div class="setup-panel__head">
<div>
<span class="pill">Custom Settings</span>
<h2>Provider und Abruf</h2>
</div>
</div>
<div class="setup-grid">
<label class="setup-field muted">
<span>FX Provider</span>
<input type="text" name="provider" value="<?= e((string) ($current['provider'] ?? 'currencyapi')) ?>">
<small class="muted">Unterstuetzt legacy currencyapi.net und currencyapi.com v3.</small>
</label>
<label class="setup-field muted">
<span>FX API Version</span>
<select name="api_version">
<option value="v2" <?= (string) ($current['api_version'] ?? 'v2') === 'v2' ? 'selected' : '' ?>>v2</option>
<option value="v3" <?= (string) ($current['api_version'] ?? 'v2') === 'v3' ? 'selected' : '' ?>>v3</option>
</select>
<small class="muted">Steuert die Endpoint-Version unabhaengig von der Domain.</small>
</label>
<label class="setup-field muted">
<span>FX API URL</span>
<input type="text" name="api_url" value="<?= e((string) ($current['api_url'] ?? 'https://currencyapi.net')) ?>">
<small class="muted">Nur die Basis-URL eintragen, z.B. https://currencyapi.net oder https://api.currencyapi.com.</small>
</label>
<label class="setup-field muted">
<span>FX API Key</span>
<input type="password" name="api_key" value="<?= e((string) ($current['api_key'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>Timeout (Sek.)</span>
<input type="number" name="timeout_sec" value="<?= e((string) ($current['timeout_sec'] ?? 10)) ?>">
</label>
<label class="setup-field muted">
<span>Max. Alter fuer API-Refresh (Min.)</span>
<input type="number" name="refresh_max_age_minutes" min="1" value="<?= e((string) ($current['refresh_max_age_minutes'] ?? 60)) ?>">
<small class="muted">API-Refresh aktualisiert nur, wenn der letzte gespeicherte Abruf aelter ist.</small>
</label>
<label class="setup-field muted">
<span>Standard-Basiswaehrung</span>
<input type="text" name="default_base_currency" value="<?= e((string) ($current['default_base_currency'] ?? 'EUR')) ?>">
</label>
</div>
</section>
<section class="setup-panel" id="setup-general">
<div class="setup-panel__head">
<div>
<span class="pill">Allgemein</span>
<h2>Datenbank und Debug</h2>
</div>
</div>
<div class="setup-grid">
<?php foreach ($generalSetupFields as $field): ?>
<?php $renderField($field); ?>
<?php endforeach; ?>
<label class="setup-field muted">
<span>Eigene Modul-DB nutzen</span>
<input type="checkbox" name="use_separate_db" value="1" <?= $fxUseSeparateDb ? 'checked' : '' ?> data-fx-db-toggle>
<small class="muted">Wenn aktiv, werden die folgenden DB-Daten verwendet. Sonst wird die Nexus-Base-DB genutzt.</small>
</label>
</div>
<div data-fx-db-section <?= $fxUseSeparateDb ? '' : 'hidden' ?>>
<div class="setup-grid" style="margin-top:16px;">
<label class="setup-field muted">
<span>DB Driver</span>
<select name="db_driver">
<option value="">Bitte waehlen</option>
<?php foreach ($driverOptions as $driver => $driverLabel): ?>
<option value="<?= e($driver) ?>" <?= (string) ($current['db']['driver'] ?? '') === $driver ? 'selected' : '' ?>><?= e($driverLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="setup-field muted">
<span>DB Host</span>
<input type="text" name="db_host" value="<?= e((string) ($current['db']['host'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>DB Port</span>
<input type="number" name="db_port" value="<?= e((string) ($current['db']['port'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>DB Name</span>
<input type="text" name="db_dbname" value="<?= e((string) ($current['db']['dbname'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>DB Schema</span>
<input type="text" name="db_schema" value="<?= e((string) ($current['db']['schema'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>DB User</span>
<input type="text" name="db_user" value="<?= e((string) ($current['db']['user'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>DB Passwort</span>
<input type="password" name="db_password" value="<?= e((string) ($current['db']['password'] ?? '')) ?>">
</label>
</div>
</div>
</section>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
<span class="pill">Aktionen</span>
<h2>Modulaktionen</h2>
</div>
</div>
<div class="setup-grid">
<div class="setup-field muted">
<span>Waehrungssynch</span>
<small class="muted">Laedt die verfuegbaren Waehrungen einmalig aus dem konfigurierten FX-Provider.</small>
<div class="setup-actions" style="justify-content:flex-start; margin-top:12px;">
<button class="nav-link" type="submit" name="module_setup_action" value="sync_currency_catalog" formnovalidate>
Waehrungskatalog synchronisieren
</button>
</div>
<?php if (trim((string) ($current['currency_catalog_synced_at'] ?? '')) !== ''): ?>
<small class="muted">Letzter Sync: <?= e((string) $current['currency_catalog_synced_at']) ?></small>
<?php endif; ?>
</div>
</div>
</section>
<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>
<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>
<div class="setup-grid">
<label class="setup-field muted">
<span>Scheduler-Zeitzone</span>
<input type="text" name="schedule_timezone" value="<?= e((string) ($current['schedule_timezone'] ?? 'Europe/Berlin')) ?>" list="timezone-options" autocomplete="off">
<small class="muted">Diese Zeitzone wird fuer Cron-Jobs und lokale Zeitangaben genutzt.</small>
</label>
</div>
</section>
<?php if ($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>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php if ($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; ?>
<div class="setup-actions setup-actions--footer">
<button class="cta-button" type="submit">Setup speichern</button>
</div>
<script>
(() => {
const toggle = document.querySelector('[data-fx-db-toggle]');
const section = document.querySelector('[data-fx-db-section]');
if (!toggle || !section) return;
const sync = () => {
section.hidden = !toggle.checked;
};
toggle.addEventListener('change', sync);
sync();
})();
(() => {
const baseRoot = document.querySelector('[data-fx-base-picker]');
const baseHidden = document.querySelector('[data-fx-base-hidden]');
const baseInput = document.querySelector('[data-fx-base-input]');
const baseSuggestions = document.querySelector('[data-fx-base-suggestions]');
const multiRoot = document.querySelector('[data-fx-multi-picker]');
const multiInput = document.querySelector('[data-fx-multi-input]');
const multiSuggestions = document.querySelector('[data-fx-multi-suggestions]');
const tagsRoot = document.querySelector('[data-fx-tags]');
const parseCurrencies = (root) => {
if (!root) return [];
try {
const parsed = JSON.parse(root.dataset.currencies || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
};
const currencies = parseCurrencies(baseRoot || multiRoot);
const formatLabel = (item) => `${item.code} - ${item.name}`;
const searchCurrencies = (query, excluded = []) => {
const needle = String(query || '').trim().toLowerCase();
const excludedSet = new Set(excluded);
const filtered = currencies.filter((item) => {
if (!item || !item.code || excludedSet.has(item.code)) {
return false;
}
if (needle === '') {
return true;
}
return item.code.toLowerCase().includes(needle) || String(item.name || '').toLowerCase().includes(needle);
});
return filtered.slice(0, 12);
};
const closeSuggestions = (node) => {
if (!node) return;
node.hidden = true;
node.innerHTML = '';
};
const renderSuggestions = (node, items, onSelect) => {
if (!node || items.length === 0) {
closeSuggestions(node);
return;
}
node.innerHTML = '';
items.forEach((item) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'fx-setup-suggestion';
button.textContent = formatLabel(item);
button.addEventListener('click', () => onSelect(item));
node.appendChild(button);
});
node.hidden = false;
};
if (baseRoot && baseHidden && baseInput && baseSuggestions) {
const applyBase = (item) => {
baseHidden.value = item.code;
baseInput.value = formatLabel(item);
closeSuggestions(baseSuggestions);
};
baseInput.addEventListener('input', () => {
baseHidden.value = '';
renderSuggestions(baseSuggestions, searchCurrencies(baseInput.value), applyBase);
});
baseInput.addEventListener('focus', () => {
renderSuggestions(baseSuggestions, searchCurrencies(baseInput.value), applyBase);
});
baseInput.form?.addEventListener('submit', () => {
if (baseHidden.value !== '') {
return;
}
const first = searchCurrencies(baseInput.value)[0] || null;
if (first) {
applyBase(first);
}
});
}
if (multiRoot && multiInput && multiSuggestions && tagsRoot) {
const selectedCodes = () => Array.from(tagsRoot.querySelectorAll('[data-code]')).map((node) => node.getAttribute('data-code') || '');
const addTag = (item) => {
if (!item || selectedCodes().includes(item.code)) {
return;
}
const tag = document.createElement('span');
tag.className = 'fx-setup-tag';
tag.setAttribute('data-code', item.code);
tag.append(document.createTextNode(item.code));
const remove = document.createElement('button');
remove.type = 'button';
remove.setAttribute('data-remove-code', item.code);
remove.setAttribute('aria-label', 'Entfernen');
remove.textContent = 'x';
remove.addEventListener('click', () => {
tag.remove();
hidden.remove();
renderSuggestions(multiSuggestions, searchCurrencies(multiInput.value, selectedCodes()), addTag);
});
tag.appendChild(remove);
tagsRoot.appendChild(tag);
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'preferred_currencies[]';
hidden.value = item.code;
hidden.setAttribute('data-code-hidden', item.code);
multiRoot.appendChild(hidden);
multiInput.value = '';
renderSuggestions(multiSuggestions, searchCurrencies('', selectedCodes()), addTag);
};
tagsRoot.querySelectorAll('[data-remove-code]').forEach((button) => {
button.addEventListener('click', () => {
const code = button.getAttribute('data-remove-code') || '';
tagsRoot.querySelector(`[data-code="${code}"]`)?.remove();
multiRoot.querySelector(`[data-code-hidden="${code}"]`)?.remove();
renderSuggestions(multiSuggestions, searchCurrencies(multiInput.value, selectedCodes()), addTag);
});
});
multiInput.addEventListener('input', () => {
renderSuggestions(multiSuggestions, searchCurrencies(multiInput.value, selectedCodes()), addTag);
});
multiInput.addEventListener('focus', () => {
renderSuggestions(multiSuggestions, searchCurrencies(multiInput.value, selectedCodes()), addTag);
});
}
document.addEventListener('click', (event) => {
if (baseRoot && !baseRoot.contains(event.target)) {
closeSuggestions(baseSuggestions);
}
if (multiRoot && !multiRoot.contains(event.target)) {
closeSuggestions(multiSuggestions);
}
});
})();
</script>
<style>
.fx-setup-picker {
position: relative;
display: grid;
gap: 10px;
}
.fx-setup-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.fx-setup-tags--stacked {
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface-strong);
}
.fx-setup-tag {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
font-size: 0.86rem;
font-weight: 700;
}
.fx-setup-tag button {
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0;
}
.fx-setup-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 20;
display: grid;
gap: 6px;
margin-top: 4px;
padding: 8px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface-strong);
box-shadow: 0 16px 35px rgba(1, 22, 32, 0.14);
max-height: 280px;
overflow: auto;
}
.fx-setup-suggestion {
appearance: none;
text-align: left;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: var(--text);
padding: 8px 10px;
cursor: pointer;
font: inherit;
}
.fx-setup-suggestion:hover,
.fx-setup-suggestion:focus-visible {
border-color: color-mix(in srgb, var(--brand-accent) 36%, transparent);
background: color-mix(in srgb, var(--brand-accent) 8%, var(--surface-strong));
outline: none;
}
</style>
<?php else: ?>
<?php if ($generalSetupFields !== []): ?>
<section class="setup-panel" id="setup-general">
<div class="setup-panel__head">
<div>
<span class="pill">Allgemein</span>
<h2>Datenbank und Debug</h2>
</div>
</div>
<div class="setup-grid">
<?php foreach ($generalSetupFields as $field): ?>
<?php $renderField($field); ?>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<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>
<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 if ($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 ($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 ($setupActions !== []): ?>
<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 ($setupActions 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 (is_array($moduleStatusPanel)): ?>
<section class="setup-panel setup-panel--flat">
<div class="setup-panel__head">
<div>
<span class="pill">Status</span>
<h2><?= e((string) $moduleStatusPanel['title']) ?></h2>
<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): ?>
<label class="setup-field muted">
<span><?= e((string) ($stat['label'] ?? '')) ?></span>
<input type="text" value="<?= e((string) ($stat['value'] ?? '')) ?>" readonly>
</label>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
<?php if ($dbGroups !== []): ?>
<section class="setup-panel"<?= $generalSetupFields === [] ? ' id="setup-general"' : '' ?>>
<div class="setup-panel__head">
<div>
<span class="pill">Datenbanken</span>
<h2>Verbindungen</h2>
<p class="muted">Jede Verbindung kann getrennt konfiguriert und getestet werden.</p>
</div>
</div>
<div class="setup-tabs" aria-label="Datenbankbereiche">
<?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>
<div class="setup-db-panels">
<?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) ?>" <?= $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 ($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>
<?php endif; ?>
</form>
</div>
<aside class="setup-shell__sidebar setup-shell__sidebar--right" aria-label="Setup-Aktionen">
<div class="setup-nav">
<span class="pill">Aktionen</span>
<a class="nav-link setup-nav__link" href="/modules">Nexus Übersicht</a>
<a class="nav-link setup-nav__link" href="/module/<?= e($moduleName) ?>">Zurück zum Modul</a>
</div>
</aside>
</div>
<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 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>
</form>
</div>