Files
nexus/partials/landingpages/modules/setup.php
Lars Gebhardt-Kusche ebbdb52f93
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
yxcyx
2026-05-11 01:29:42 +02:00

2194 lines
96 KiB
PHP

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