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

2356 lines
105 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 das Modul Debug-Daten sammeln und den Debug-Bereich anzeigen.',
];
}
$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 : [],
];
$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;
}
$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);
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;
}
}
$moduleStatusPanel = null;
$activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
? $testGroup
: (array_key_first($dbGroups) ?? '');
?>
<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; ?>
<form method="post" class="setup-form">
<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">
<div class="setup-panel__head">
<div>
<span class="pill">API</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">
<div class="setup-panel__head">
<div>
<span class="pill">Waehrungen</span>
<h2>Waehrungseinstellungen</h2>
<p class="muted">Die Auswahl wird erst verfuegbar, nachdem der Waehrungskatalog synchronisiert wurde.</p>
</div>
</div>
<?php if (!$fxCatalogAvailable): ?>
<div class="setup-db-message setup-db-message--hint">
Noch kein Waehrungskatalog vorhanden. Fuehre zuerst unten die Modulaktion <strong>Waehrungskatalog synchronisieren</strong> aus.
</div>
<?php else: ?>
<div class="setup-grid">
<label class="setup-field muted">
<span>Anzeige-Basiswaehrung</span>
<input type="hidden" name="display_base_currency" value="<?= e((string) ($current['display_base_currency'] ?? '')) ?>" data-fx-base-hidden>
<div class="fx-setup-picker" data-fx-base-picker data-currencies='<?= e(is_string($fxCatalogJson) ? $fxCatalogJson : '[]') ?>'>
<input
type="text"
value="<?= e((string) (($current['display_base_currency'] ?? '') !== '' && isset($fxCatalogOptions[(string) $current['display_base_currency']]) ? ((string) $current['display_base_currency'] . ' - ' . $fxCatalogOptions[(string) $current['display_base_currency']]) : '')) ?>"
placeholder="Waehrung suchen"
autocomplete="off"
data-fx-base-input
>
<div class="fx-setup-suggestions" data-fx-base-suggestions hidden></div>
</div>
<small class="muted">Suche nach Code oder Bezeichnung und waehle einen Treffer aus.</small>
</label>
<label class="setup-field muted">
<span>Bevorzugte Waehrungen</span>
<div class="fx-setup-picker" data-fx-multi-picker data-currencies='<?= e(is_string($fxCatalogJson) ? $fxCatalogJson : '[]') ?>'>
<input type="text" value="" placeholder="Waehrung suchen und hinzufuegen" autocomplete="off" data-fx-multi-input>
<div class="fx-setup-suggestions" data-fx-multi-suggestions hidden></div>
<div class="fx-setup-tags fx-setup-tags--stacked" data-fx-tags>
<?php foreach ($fxPreferred as $code): ?>
<?php if (!isset($fxCatalogOptions[$code])) { continue; } ?>
<span class="fx-setup-tag" data-code="<?= e($code) ?>">
<?= e($code) ?>
<button type="button" data-remove-code="<?= e($code) ?>" aria-label="Entfernen">x</button>
</span>
<input type="hidden" name="preferred_currencies[]" value="<?= e($code) ?>" data-code-hidden="<?= e($code) ?>">
<?php endforeach; ?>
</div>
</div>
<small class="muted">Tippen, Treffer anklicken und zur Liste hinzufuegen.</small>
</label>
</div>
<?php endif; ?>
</section>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
<span class="pill">Scheduler</span>
<h2>Cron-Zeitzone</h2>
</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 die lokale Zeitberechnung verwendet.</small>
</label>
</div>
</section>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
<span class="pill">DB</span>
<h2>Datenbank</h2>
</div>
</div>
<div class="setup-grid">
<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>
<label class="setup-field muted">
<span>Modul-Debug aktivieren</span>
<input type="checkbox" name="debug_enabled" value="1" <?= !empty($current['debug_enabled']) ? 'checked' : '' ?>>
<small class="muted">Wenn aktiv, darf das Modul Debug-Daten sammeln und den Debug-Bereich anzeigen.</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">
<button class="cta-button" type="submit">Speichern</button>
<a class="nav-link" href="/modules/access/<?= e($moduleName) ?>">Zugriff verwalten</a>
<a class="nav-link" href="/modules">Zurück</a>
</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 ($generalFields !== []): ?>
<section class="setup-panel">
<div class="setup-panel__head">
<div>
<span class="pill">Allgemein</span>
<h2>Moduleinstellungen</h2>
</div>
</div>
<div class="setup-grid">
<?php foreach ($generalFields as $field): ?>
<?php $renderField($field); ?>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?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">
<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">
<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; ?>
<div class="setup-actions">
<button class="cta-button" type="submit">Speichern</button>
<a class="nav-link" href="/modules/access/<?= e($moduleName) ?>">Zugriff verwalten</a>
<a class="nav-link" href="/modules">Zurück</a>
</div>
<?php endif; ?>
<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>