diff --git a/modules/pihole/api/index.php b/modules/pihole/api/index.php
new file mode 100644
index 00000000..c9617e19
--- /dev/null
+++ b/modules/pihole/api/index.php
@@ -0,0 +1,1166 @@
+settings('pihole');
+
+$body = file_get_contents('php://input');
+$payload = [];
+if ($body !== false && $body !== '') {
+ $decoded = json_decode($body, true);
+ if (is_array($decoded)) {
+ $payload = $decoded;
+ }
+}
+
+$respond = function (array $data, int $status = 200): void {
+ http_response_code($status);
+ echo json_encode($data, JSON_UNESCAPED_UNICODE);
+ exit;
+};
+
+$debugPush = static function (string $label, array $payload = []): void {
+ module_debug_push('pihole', array_merge(['label' => $label], $payload));
+};
+
+$sessionFingerprint = static function (array $instance): string {
+ return sha1(json_encode([
+ 'id' => (string)($instance['id'] ?? ''),
+ 'url' => (string)($instance['url'] ?? ''),
+ 'password' => (string)($instance['password'] ?? ''),
+ ], JSON_UNESCAPED_UNICODE));
+};
+
+$getSessionBucket = static function (): array {
+ $bucket = $_SESSION['pihole_api_sessions'] ?? [];
+ return is_array($bucket) ? $bucket : [];
+};
+
+$readSessionCache = static function (array $instance) use ($getSessionBucket, $sessionFingerprint): ?array {
+ $bucket = $getSessionBucket();
+ $key = (string)($instance['id'] ?? '');
+ if ($key === '' || !isset($bucket[$key]) || !is_array($bucket[$key])) {
+ return null;
+ }
+
+ $row = $bucket[$key];
+ if (($row['fingerprint'] ?? '') !== $sessionFingerprint($instance)) {
+ return null;
+ }
+
+ return $row;
+};
+
+$writeSessionCache = static function (array $instance, string $sid, int $validity = 300, ?string $csrf = null) use ($sessionFingerprint): void {
+ $key = (string)($instance['id'] ?? '');
+ if ($key === '' || $sid === '') {
+ return;
+ }
+
+ if (!isset($_SESSION['pihole_api_sessions']) || !is_array($_SESSION['pihole_api_sessions'])) {
+ $_SESSION['pihole_api_sessions'] = [];
+ }
+
+ $_SESSION['pihole_api_sessions'][$key] = [
+ 'fingerprint' => $sessionFingerprint($instance),
+ 'sid' => $sid,
+ 'csrf' => $csrf ?? '',
+ 'validity' => $validity > 0 ? $validity : 300,
+ 'expires_at' => time() + max(30, $validity - 15),
+ 'updated_at' => time(),
+ ];
+};
+
+$touchSessionCache = static function (array $instance, ?int $validity = null) use ($readSessionCache, $writeSessionCache): void {
+ $cached = $readSessionCache($instance);
+ if (!$cached || empty($cached['sid'])) {
+ return;
+ }
+
+ $writeSessionCache(
+ $instance,
+ (string)$cached['sid'],
+ $validity ?? (int)($cached['validity'] ?? 300),
+ (string)($cached['csrf'] ?? '')
+ );
+};
+
+$clearSessionCache = static function (array $instance): void {
+ $key = (string)($instance['id'] ?? '');
+ if ($key === '' || !isset($_SESSION['pihole_api_sessions']) || !is_array($_SESSION['pihole_api_sessions'])) {
+ return;
+ }
+
+ unset($_SESSION['pihole_api_sessions'][$key]);
+};
+
+$normalizeApiPath = function (string $baseUrl, string $apiPath): string {
+ $base = rtrim($baseUrl, '/');
+ $path = $apiPath;
+ if ($path === '') {
+ $path = '/admin/api.php';
+ }
+ if ($path[0] !== '/') {
+ $path = '/' . $path;
+ }
+ return $base . $path;
+};
+
+$httpRequest = function (string $method, string $url, array $headers, ?string $body, bool $verify, int $timeout): array {
+ $raw = '';
+ $httpCode = 0;
+ $error = '';
+
+ if (function_exists('curl_init')) {
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => $timeout,
+ CURLOPT_CONNECTTIMEOUT => $timeout,
+ CURLOPT_SSL_VERIFYPEER => $verify,
+ CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0,
+ CURLOPT_CUSTOMREQUEST => $method,
+ CURLOPT_HTTPHEADER => $headers,
+ ]);
+ if ($body !== null) {
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+ }
+ $raw = (string)curl_exec($ch);
+ if ($raw === '' && curl_errno($ch)) {
+ $error = curl_error($ch);
+ }
+ $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+ } else {
+ $ctx = stream_context_create([
+ 'http' => [
+ 'method' => $method,
+ 'timeout' => $timeout,
+ 'header' => implode("\r\n", $headers),
+ 'content' => $body ?? '',
+ ],
+ 'ssl' => [
+ 'verify_peer' => $verify,
+ 'verify_peer_name' => $verify,
+ ],
+ ]);
+ $raw = (string)@file_get_contents($url, false, $ctx);
+ $httpCode = 200;
+ if ($raw === '') {
+ $error = 'HTTP request failed';
+ }
+ }
+
+ if ($error !== '') {
+ return ['ok' => false, 'error' => $error, 'http_code' => $httpCode, 'url' => $url];
+ }
+
+ $data = json_decode($raw, true);
+ if (!is_array($data)) {
+ return ['ok' => false, 'error' => 'invalid_json', 'http_code' => $httpCode, 'raw' => $raw, 'url' => $url];
+ }
+
+ if ($httpCode >= 400) {
+ $apiError = is_array($data['error'] ?? null) ? $data['error'] : [];
+ return [
+ 'ok' => false,
+ 'error' => (string)($apiError['message'] ?? $apiError['key'] ?? ('HTTP ' . $httpCode)),
+ 'error_key' => (string)($apiError['key'] ?? ''),
+ 'hint' => $apiError['hint'] ?? null,
+ 'http_code' => $httpCode,
+ 'data' => $data,
+ 'url' => $url,
+ ];
+ }
+
+ if (isset($data['error'])) {
+ $apiError = is_array($data['error']) ? $data['error'] : [];
+ return [
+ 'ok' => false,
+ 'error' => (string)($apiError['message'] ?? $apiError['key'] ?? 'api_error'),
+ 'error_key' => (string)($apiError['key'] ?? ''),
+ 'hint' => $apiError['hint'] ?? null,
+ 'http_code' => $httpCode,
+ 'data' => $data,
+ 'url' => $url,
+ ];
+ }
+
+ return ['ok' => true, 'data' => $data, 'http_code' => $httpCode, 'url' => $url];
+};
+
+$v5Request = function (array $instance, array $params, ?int $timeoutOverride = null) use ($normalizeApiPath, $httpRequest): array {
+ if (!empty($instance['password']) && !isset($params['auth'])) {
+ $params['auth'] = $instance['password'];
+ }
+
+ $url = $normalizeApiPath((string)$instance['url'], (string)$instance['api_path']);
+ $qs = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
+ $full = $qs !== '' ? $url . '?' . $qs : $url;
+
+ $timeout = $timeoutOverride ?? (int)($instance['timeout'] ?? 8);
+ if ($timeout <= 0) {
+ $timeout = 8;
+ }
+ $verify = !empty($instance['verify_tls']);
+
+ return $httpRequest('GET', $full, ['Accept: application/json'], null, $verify, $timeout);
+};
+
+$v6Logout = function (array $instance, string $sid) use ($httpRequest): void {
+ if ($sid === '') {
+ return;
+ }
+
+ $base = rtrim((string)$instance['url'], '/');
+ $url = $base . '/api/auth?sid=' . rawurlencode($sid);
+ $timeout = (int)($instance['timeout'] ?? 8);
+ if ($timeout <= 0) {
+ $timeout = 8;
+ }
+ $verify = !empty($instance['verify_tls']);
+ $httpRequest('DELETE', $url, ['Accept: application/json', 'X-FTL-SID: ' . $sid], null, $verify, $timeout);
+};
+
+$v6Auth = function (array $instance) use ($httpRequest, $readSessionCache, $writeSessionCache, $clearSessionCache, $v6Logout): array {
+ $cached = $readSessionCache($instance);
+ if ($cached && !empty($cached['sid']) && (int)($cached['expires_at'] ?? 0) > time()) {
+ return [
+ 'ok' => true,
+ 'http_code' => 200,
+ 'url' => rtrim((string)$instance['url'], '/') . '/api/auth',
+ 'data' => ['session' => ['sid' => (string)$cached['sid'], 'validity' => (int)($cached['validity'] ?? 300)]],
+ 'sid' => (string)$cached['sid'],
+ 'cached' => true,
+ ];
+ }
+
+ $base = rtrim((string)$instance['url'], '/');
+ $url = $base . '/api/auth';
+ $timeout = (int)($instance['timeout'] ?? 8);
+ if ($timeout <= 0) {
+ $timeout = 8;
+ }
+ $verify = !empty($instance['verify_tls']);
+ $payload = ['password' => (string)($instance['password'] ?? '')];
+ $body = json_encode($payload, JSON_UNESCAPED_UNICODE);
+ $res = $httpRequest('POST', $url, ['Accept: application/json', 'Content-Type: application/json'], $body, $verify, $timeout);
+ if (!$res['ok']) {
+ $clearSessionCache($instance);
+ return $res;
+ }
+ $data = (array)($res['data'] ?? []);
+ $session = (array)($data['session'] ?? []);
+ $sid = (string)($session['sid'] ?? '');
+ $validity = (int)($session['validity'] ?? 300);
+ $csrf = (string)($session['csrf'] ?? '');
+ if ($cached && !empty($cached['sid']) && $cached['sid'] !== $sid) {
+ $v6Logout($instance, (string)$cached['sid']);
+ }
+ if ($sid !== '') {
+ $writeSessionCache($instance, $sid, $validity, $csrf);
+ }
+ $res['sid'] = $sid;
+ $res['cached'] = false;
+ return $res;
+};
+
+$v6Request = function (array $instance, string $path, string $method, array $payload, string $sid, ?int $timeoutOverride = null) use ($httpRequest, $clearSessionCache, $touchSessionCache): array {
+ $base = rtrim((string)$instance['url'], '/');
+ $path = ltrim($path, '/');
+ $url = $base . '/api/' . $path;
+ $timeout = $timeoutOverride ?? (int)($instance['timeout'] ?? 8);
+ if ($timeout <= 0) {
+ $timeout = 8;
+ }
+ $verify = !empty($instance['verify_tls']);
+ $headers = ['Accept: application/json'];
+ if ($sid !== '') {
+ $headers[] = 'sid: ' . $sid;
+ $headers[] = 'X-FTL-SID: ' . $sid;
+ }
+
+ $body = null;
+ if ($method !== 'GET') {
+ if ($sid !== '' && !isset($payload['sid'])) {
+ $payload['sid'] = $sid;
+ }
+ $body = json_encode($payload, JSON_UNESCAPED_UNICODE);
+ $headers[] = 'Content-Type: application/json';
+ }
+
+ $result = $httpRequest($method, $url, $headers, $body, $verify, $timeout);
+ $httpCode = (int)($result['http_code'] ?? 0);
+ if ($sid !== '') {
+ if (($result['ok'] ?? false) === true) {
+ $touchSessionCache($instance);
+ } elseif (in_array($httpCode, [401, 403], true)) {
+ $clearSessionCache($instance);
+ }
+ }
+
+ return $result;
+};
+
+$v6RequestAny = function (array $instance, array $paths, string $method, array $payload, string $sid, ?int $timeoutOverride = null) use ($v6Request): array {
+ $last = ['ok' => false, 'error' => 'no_path', 'http_code' => 0, 'url' => ''];
+ foreach ($paths as $path) {
+ $result = $v6Request($instance, (string)$path, $method, $payload, $sid, $timeoutOverride);
+ if (($result['ok'] ?? false) === true) {
+ return $result;
+ }
+ $last = $result;
+ $httpCode = (int)($result['http_code'] ?? 0);
+ if (!in_array($httpCode, [0, 400, 404], true)) {
+ return $result;
+ }
+ }
+ return $last;
+};
+
+$detectApi = function (array $instance) use ($v6Auth, $v6RequestAny, $v5Request, $clearSessionCache): array {
+ $sid = '';
+ $authRes = null;
+ if (!empty($instance['password'])) {
+ $authRes = $v6Auth($instance);
+ if (($authRes['ok'] ?? false) && !empty($authRes['sid'])) {
+ $sid = (string)$authRes['sid'];
+
+ $probe = $v6RequestAny($instance, ['stats/summary', 'summary'], 'GET', [], $sid);
+ if (!($probe['ok'] ?? false)) {
+ $versionProbe = $v6RequestAny($instance, ['dns/blocking'], 'GET', [], $sid);
+ if (($versionProbe['ok'] ?? false) || in_array((int)($versionProbe['http_code'] ?? 0), [401, 403], true)) {
+ $probe = $versionProbe;
+ }
+ }
+ if (!(empty($authRes['cached'])) && in_array((int)($probe['http_code'] ?? 0), [401, 403], true)) {
+ $clearSessionCache($instance);
+ $authRes = $v6Auth($instance);
+ if (($authRes['ok'] ?? false) && !empty($authRes['sid'])) {
+ $sid = (string)$authRes['sid'];
+ $probe = $v6RequestAny($instance, ['stats/summary', 'summary'], 'GET', [], $sid);
+ if (!($probe['ok'] ?? false)) {
+ $versionProbe = $v6RequestAny($instance, ['dns/blocking'], 'GET', [], $sid);
+ if (($versionProbe['ok'] ?? false) || in_array((int)($versionProbe['http_code'] ?? 0), [401, 403], true)) {
+ $probe = $versionProbe;
+ }
+ }
+ }
+ }
+ return ['version' => 6, 'sid' => $sid, 'probe' => $probe, 'auth' => $authRes];
+ }
+
+ $httpCode = (int)($authRes['http_code'] ?? 0);
+ if (in_array($httpCode, [401, 403], true)) {
+ return ['version' => 6, 'sid' => '', 'probe' => $authRes, 'auth' => $authRes];
+ }
+ }
+
+ $probe = $v6RequestAny($instance, ['stats/summary', 'summary', 'dns/blocking'], 'GET', [], $sid);
+ if ($probe['ok'] || in_array((int)($probe['http_code'] ?? 0), [401, 403], true)) {
+ return ['version' => 6, 'sid' => $sid, 'probe' => $probe, 'auth' => $authRes];
+ }
+
+ $legacy = $v5Request($instance, ['summaryRaw' => 1]);
+ if ($legacy['ok']) {
+ return ['version' => 5, 'sid' => '', 'probe' => $legacy];
+ }
+
+ return ['version' => 0, 'sid' => '', 'probe' => $probe, 'legacy' => $legacy, 'auth' => $authRes];
+};
+
+$debugResult = static function (string $label, string $instanceId, array $instance, array $result) use ($debugPush): void {
+ $payload = [
+ 'instance_id' => $instanceId,
+ 'instance_name' => (string)($instance['name'] ?? $instanceId),
+ 'url' => (string)($instance['url'] ?? ''),
+ 'ok' => (bool)($result['ok'] ?? false),
+ 'http_code' => (int)($result['http_code'] ?? 0),
+ 'error' => (string)($result['error'] ?? ''),
+ 'error_key' => (string)($result['error_key'] ?? ''),
+ 'hint' => $result['hint'] ?? null,
+ 'request_url' => (string)($result['url'] ?? ''),
+ ];
+
+ if (isset($result['data']) && is_array($result['data'])) {
+ $payload['response'] = $result['data'];
+ } elseif (isset($result['raw'])) {
+ $payload['raw'] = (string)$result['raw'];
+ }
+
+ $debugPush($label, $payload);
+};
+
+$resolvePrimaryId = function () use ($instances): ?string {
+ foreach ($instances as $id => $row) {
+ if (!empty($row['is_primary'])) {
+ return $id;
+ }
+ }
+ $first = array_key_first($instances);
+ return $first !== null ? (string)$first : null;
+};
+
+$resolveActionTimeout = static function () use ($moduleSettings): int {
+ $timeout = (int)($moduleSettings['action_timeout_sec'] ?? 120);
+ return $timeout > 0 ? $timeout : 120;
+};
+
+$pickInstances = function (string $target) use ($instances, $resolvePrimaryId): array {
+ if ($target === 'all') {
+ return $instances;
+ }
+ if ($target === 'primary') {
+ $primaryId = $resolvePrimaryId();
+ if ($primaryId !== null && isset($instances[$primaryId])) {
+ return [$primaryId => $instances[$primaryId]];
+ }
+ }
+ if (isset($instances[$target])) {
+ return [$target => $instances[$target]];
+ }
+ return [];
+};
+
+$aggregateTopList = function (array $items, array &$bucket): void {
+ foreach ($items as $label => $count) {
+ if (!is_string($label)) {
+ continue;
+ }
+ $bucket[$label] = ($bucket[$label] ?? 0) + (int)$count;
+ }
+};
+
+$aggregateMap = function (array $map, array &$bucket): void {
+ foreach ($map as $label => $count) {
+ if (!is_string($label)) {
+ continue;
+ }
+ $bucket[$label] = ($bucket[$label] ?? 0) + (int)$count;
+ }
+};
+
+$extractQueryTypes = function (array $data): array {
+ if (isset($data['querytypes']) && is_array($data['querytypes'])) {
+ return $data['querytypes'];
+ }
+ return $data;
+};
+
+$extractForwardDestinations = function (array $data): array {
+ if (isset($data['forward_destinations']) && is_array($data['forward_destinations'])) {
+ return $data['forward_destinations'];
+ }
+ return $data;
+};
+
+$extractSources = function (array $data): array {
+ if (isset($data['query_sources']) && is_array($data['query_sources'])) {
+ return $data['query_sources'];
+ }
+ return $data;
+};
+
+$parseUpdates = function (?array $versions): array {
+ if (!$versions) {
+ return ['available' => false, 'details' => []];
+ }
+ $details = [];
+ $available = false;
+ foreach (['core', 'web', 'FTL'] as $key) {
+ $current = $versions[$key . '_current'] ?? null;
+ $latest = $versions[$key . '_latest'] ?? null;
+ $updateFlag = $versions[$key . '_update'] ?? null;
+ $needs = false;
+ if (is_string($updateFlag)) {
+ $needs = $updateFlag === 'true' || $updateFlag === '1';
+ } elseif (is_bool($updateFlag)) {
+ $needs = $updateFlag;
+ }
+ if ($current && $latest && $current !== $latest) {
+ $needs = true;
+ }
+ $details[$key] = [
+ 'current' => $current,
+ 'latest' => $latest,
+ 'update' => $needs,
+ ];
+ if ($needs) {
+ $available = true;
+ }
+ }
+ return ['available' => $available, 'details' => $details];
+};
+
+if ($action === 'dashboard') {
+ if (empty($instances)) {
+ $respond(['ok' => false, 'error' => 'no_instances'], 400);
+ }
+
+ $debugPush('dashboard.request', [
+ 'action' => $action,
+ 'instance_count' => count($instances),
+ ]);
+
+ $aggregate = [
+ 'summary' => [
+ 'dns_queries_today' => 0,
+ 'ads_blocked_today' => 0,
+ 'unique_clients' => 0,
+ 'unique_domains' => 0,
+ 'blocked_domains' => 0,
+ 'queries_forwarded' => 0,
+ 'queries_cached' => 0,
+ 'status' => 'unknown',
+ ],
+ 'top_ads' => [],
+ 'top_queries' => [],
+ 'query_types' => [],
+ 'query_sources' => [],
+ 'forward_destinations' => [],
+ 'recent_blocked' => [],
+ 'updates' => ['available' => false, 'details' => []],
+ ];
+
+ $instancePayloads = [];
+ $statuses = [];
+
+ $makeError = function (string $scope, array $result): array {
+ return [
+ 'scope' => $scope,
+ 'error' => $result['error'] ?? 'error',
+ 'error_key' => $result['error_key'] ?? '',
+ 'hint' => $result['hint'] ?? null,
+ 'http_code' => $result['http_code'] ?? 0,
+ 'url' => $result['url'] ?? '',
+ ];
+ };
+
+ $now = time();
+ $from = $now - 3600;
+
+ foreach ($instances as $id => $instance) {
+ $errors = [];
+ $summaryData = null;
+ $topData = null;
+ $queryTypesData = null;
+ $querySourcesData = null;
+ $forwardDestData = null;
+ $recentData = null;
+ $updates = ['available' => false, 'details' => []];
+ $versionsData = null;
+
+ $apiInfo = $detectApi($instance);
+ $debugPush('api.detect.result', [
+ 'instance_id' => $id,
+ 'instance_name' => (string)($instance['name'] ?? $id),
+ 'version' => (int)($apiInfo['version'] ?? 0),
+ 'sid_present' => !empty($apiInfo['sid']),
+ ]);
+ if ($apiInfo['version'] === 6) {
+ $sid = (string)($apiInfo['sid'] ?? '');
+ $summary = $apiInfo['probe'];
+ $debugResult('dashboard.summary', $id, $instance, $summary);
+ if (!$summary['ok']) {
+ $errors[] = $makeError('summary', $summary);
+ }
+
+ $blocking = $v6RequestAny($instance, ['dns/blocking'], 'GET', [], $sid);
+ $debugResult('dashboard.blocking', $id, $instance, $blocking);
+ if (!$blocking['ok']) {
+ $errors[] = $makeError('blocking', $blocking);
+ }
+
+ $queries = $v6RequestAny($instance, [
+ 'queries',
+ 'queries/all',
+ 'queries?from=' . $from . '&until=' . $now,
+ 'queries/all?from=' . $from . '&until=' . $now,
+ ], 'GET', [], $sid);
+ $debugResult('dashboard.queries', $id, $instance, $queries);
+ if (!$queries['ok']) {
+ $errors[] = $makeError('queries', $queries);
+ }
+
+ $upstreams = $v6RequestAny($instance, ['stats/upstreams', 'upstreams'], 'GET', [], $sid);
+ $debugResult('dashboard.upstreams', $id, $instance, $upstreams);
+ if (!$upstreams['ok']) {
+ $errors[] = $makeError('upstreams', $upstreams);
+ }
+
+ if ($summary['ok'] && is_array($summary['data'])) {
+ $sum = $summary['data'];
+ $queriesBlock = (array)($sum['queries'] ?? []);
+ $clientsBlock = (array)($sum['clients'] ?? []);
+ $status = 'unknown';
+ if ($blocking['ok'] && is_array($blocking['data'])) {
+ $blockingState = $blocking['data']['blocking'] ?? null;
+ if ($blockingState === true || $blockingState === 'enabled') {
+ $status = 'enabled';
+ } elseif ($blockingState === false || $blockingState === 'disabled') {
+ $status = 'disabled';
+ }
+ }
+
+ $summaryData = [
+ 'dns_queries_today' => (int)($queriesBlock['total'] ?? 0),
+ 'ads_blocked_today' => (int)($queriesBlock['blocked'] ?? 0),
+ 'unique_clients' => (int)($clientsBlock['active'] ?? $clientsBlock['total'] ?? 0),
+ 'unique_domains' => (int)($queriesBlock['unique_domains'] ?? 0),
+ 'blocked_domains' => (int)(
+ $sum['domains_being_blocked']
+ ?? $sum['gravity']['domains_being_blocked']
+ ?? $sum['gravity']['domains']
+ ?? $sum['gravity']['blocked']
+ ?? 0
+ ),
+ 'queries_forwarded' => (int)($queriesBlock['forwarded'] ?? 0),
+ 'queries_cached' => (int)($queriesBlock['cached'] ?? 0),
+ 'status' => $status,
+ ];
+ if ($summaryData['dns_queries_today'] > 0) {
+ $summaryData['ads_percentage_today'] = round(
+ $summaryData['ads_blocked_today'] / $summaryData['dns_queries_today'] * 100,
+ 2
+ );
+ } else {
+ $summaryData['ads_percentage_today'] = 0;
+ }
+ $queryTypesData = (array)($queriesBlock['types'] ?? []);
+ }
+
+ if ($queries['ok'] && is_array($queries['data'])) {
+ $queryList = [];
+ if (isset($queries['data']['queries']) && is_array($queries['data']['queries'])) {
+ $queryList = $queries['data']['queries'];
+ } elseif (isset($queries['data']['data']) && is_array($queries['data']['data'])) {
+ $queryList = $queries['data']['data'];
+ } elseif (array_is_list($queries['data'])) {
+ $queryList = $queries['data'];
+ }
+ $topAds = [];
+ $topQueries = [];
+ $sources = [];
+ $recent = [];
+
+ foreach ($queryList as $entry) {
+ if (!is_array($entry)) {
+ continue;
+ }
+ $domainRaw = $entry['domain'] ?? '';
+ $domain = '';
+ if (is_array($domainRaw)) {
+ $domain = (string)($domainRaw['name'] ?? $domainRaw['domain'] ?? '');
+ } else {
+ $domain = (string)$domainRaw;
+ }
+
+ $clientRaw = $entry['client'] ?? '';
+ $client = '';
+ if (is_array($clientRaw)) {
+ $name = (string)($clientRaw['name'] ?? '');
+ $ip = (string)($clientRaw['ip'] ?? '');
+ $client = $name !== '' ? ($name . ' (' . $ip . ')') : ($ip !== '' ? $ip : 'unknown');
+ } else {
+ $client = (string)$clientRaw;
+ }
+
+ if ($client !== '') {
+ $sources[$client] = ($sources[$client] ?? 0) + 1;
+ }
+ if ($domain !== '') {
+ $topQueries[$domain] = ($topQueries[$domain] ?? 0) + 1;
+ }
+
+ $statusVal = strtoupper((string)($entry['status'] ?? ''));
+ $isBlocked = str_contains($statusVal, 'GRAVITY')
+ || str_contains($statusVal, 'BLOCK')
+ || str_contains($statusVal, 'DENY')
+ || str_contains($statusVal, 'CNAME');
+ if ($isBlocked && $domain !== '') {
+ $topAds[$domain] = ($topAds[$domain] ?? 0) + 1;
+ $recent[] = ['domain' => $domain, 'instance' => $instance['name']];
+ }
+ }
+
+ $topData = ['top_ads' => $topAds, 'top_queries' => $topQueries];
+ $querySourcesData = $sources;
+ $recentData = $recent;
+ }
+
+ if ($upstreams['ok'] && is_array($upstreams['data'])) {
+ $upList = [];
+ if (isset($upstreams['data']['upstreams']) && is_array($upstreams['data']['upstreams'])) {
+ $upList = $upstreams['data']['upstreams'];
+ } elseif (isset($upstreams['data']['data']) && is_array($upstreams['data']['data'])) {
+ $upList = $upstreams['data']['data'];
+ } elseif (array_is_list($upstreams['data'])) {
+ $upList = $upstreams['data'];
+ }
+ $forwardDestData = [];
+ foreach ($upList as $item) {
+ if (!is_array($item)) {
+ continue;
+ }
+ $label = (string)($item['name'] ?? $item['ip'] ?? '');
+ if ($label === '' && isset($item['port'])) {
+ $label = 'upstream:' . (string)$item['port'];
+ }
+ if ($label === '') {
+ continue;
+ }
+ $forwardDestData[$label] = (int)($item['count'] ?? 0);
+ }
+ }
+ } elseif ($apiInfo['version'] === 5) {
+ $summary = $apiInfo['probe'];
+ $topItems = $v5Request($instance, ['topItems' => 50]);
+ $queryTypes = $v5Request($instance, ['getQueryTypes' => 1]);
+ $querySources = $v5Request($instance, ['getQuerySources' => 1]);
+ $forwardDest = $v5Request($instance, ['getForwardDestinations' => 1]);
+ $recentBlocked = $v5Request($instance, ['recentBlocked' => 30]);
+ $versions = $v5Request($instance, ['versions' => 1]);
+ $debugResult('dashboard.summary', $id, $instance, $summary);
+ $debugResult('dashboard.topItems', $id, $instance, $topItems);
+ $debugResult('dashboard.queryTypes', $id, $instance, $queryTypes);
+ $debugResult('dashboard.querySources', $id, $instance, $querySources);
+ $debugResult('dashboard.forwardDestinations', $id, $instance, $forwardDest);
+ $debugResult('dashboard.recentBlocked', $id, $instance, $recentBlocked);
+ $debugResult('dashboard.versions', $id, $instance, $versions);
+
+ if (!$summary['ok']) {
+ $errors[] = $makeError('summary', $summary);
+ }
+ if (!$topItems['ok']) {
+ $errors[] = $makeError('topItems', $topItems);
+ }
+ if (!$queryTypes['ok']) {
+ $errors[] = $makeError('queryTypes', $queryTypes);
+ }
+ if (!$querySources['ok']) {
+ $errors[] = $makeError('querySources', $querySources);
+ }
+ if (!$forwardDest['ok']) {
+ $errors[] = $makeError('forwardDestinations', $forwardDest);
+ }
+ if (!$recentBlocked['ok']) {
+ $errors[] = $makeError('recentBlocked', $recentBlocked);
+ }
+ if (!$versions['ok']) {
+ $errors[] = $makeError('versions', $versions);
+ }
+
+ $summaryData = $summary['ok'] ? $summary['data'] : null;
+ $topData = $topItems['ok'] ? $topItems['data'] : null;
+ $queryTypesData = $queryTypes['ok'] ? $extractQueryTypes($queryTypes['data']) : null;
+ $querySourcesData = $querySources['ok'] ? $extractSources($querySources['data']) : null;
+ $forwardDestData = $forwardDest['ok'] ? $extractForwardDestinations($forwardDest['data']) : null;
+ $recentData = $recentBlocked['ok'] ? $recentBlocked['data'] : null;
+ $versionsData = $versions['ok'] ? $versions['data'] : null;
+ $updates = $parseUpdates(is_array($versionsData) ? $versionsData : null);
+ } else {
+ $probe = $apiInfo['probe'] ?? ['error' => 'unknown'];
+ if (is_array($probe)) {
+ $debugResult('dashboard.probe', $id, $instance, $probe);
+ }
+ $errors[] = $makeError('probe', is_array($probe) ? $probe : ['error' => 'unknown']);
+ }
+
+ if (is_array($summaryData)) {
+ $aggregate['summary']['dns_queries_today'] += (int)($summaryData['dns_queries_today'] ?? 0);
+ $aggregate['summary']['ads_blocked_today'] += (int)($summaryData['ads_blocked_today'] ?? 0);
+ $aggregate['summary']['unique_clients'] += (int)($summaryData['unique_clients'] ?? 0);
+ $aggregate['summary']['unique_domains'] += (int)($summaryData['unique_domains'] ?? 0);
+ $aggregate['summary']['blocked_domains'] += (int)($summaryData['blocked_domains'] ?? 0);
+ $aggregate['summary']['queries_forwarded'] += (int)($summaryData['queries_forwarded'] ?? 0);
+ $aggregate['summary']['queries_cached'] += (int)($summaryData['queries_cached'] ?? 0);
+ $status = (string)($summaryData['status'] ?? 'unknown');
+ $statuses[] = $status;
+ }
+
+ if (is_array($topData)) {
+ if (!empty($topData['top_ads']) && is_array($topData['top_ads'])) {
+ $aggregateTopList($topData['top_ads'], $aggregate['top_ads']);
+ }
+ if (!empty($topData['top_queries']) && is_array($topData['top_queries'])) {
+ $aggregateTopList($topData['top_queries'], $aggregate['top_queries']);
+ }
+ }
+
+ if (is_array($queryTypesData)) {
+ $aggregateMap($queryTypesData, $aggregate['query_types']);
+ }
+
+ if (is_array($querySourcesData)) {
+ $aggregateMap($querySourcesData, $aggregate['query_sources']);
+ }
+
+ if (is_array($forwardDestData)) {
+ $aggregateMap($forwardDestData, $aggregate['forward_destinations']);
+ }
+
+ if (is_array($recentData)) {
+ foreach ($recentData as $entry) {
+ if (is_string($entry)) {
+ $aggregate['recent_blocked'][] = ['domain' => $entry, 'instance' => $instance['name']];
+ } elseif (is_array($entry) && isset($entry['domain'])) {
+ $aggregate['recent_blocked'][] = ['domain' => (string)$entry['domain'], 'instance' => $instance['name']];
+ }
+ }
+ }
+
+ if (!empty($updates['available'])) {
+ $aggregate['updates']['available'] = true;
+ }
+ $aggregate['updates']['details'][$id] = $updates;
+
+ $instancePayloads[$id] = [
+ 'meta' => [
+ 'id' => $id,
+ 'name' => $instance['name'],
+ 'url' => $instance['url'],
+ 'is_primary' => !empty($instance['is_primary']),
+ ],
+ 'summary' => $summaryData,
+ 'top_items' => $topData,
+ 'query_types' => $queryTypesData,
+ 'query_sources' => $querySourcesData,
+ 'forward_destinations' => $forwardDestData,
+ 'recent_blocked' => $recentData,
+ 'versions' => $versionsData,
+ 'updates' => $updates,
+ 'errors' => $errors,
+ ];
+ }
+
+ if ($aggregate['summary']['dns_queries_today'] > 0) {
+ $aggregate['summary']['ads_percentage_today'] = round(
+ $aggregate['summary']['ads_blocked_today'] / $aggregate['summary']['dns_queries_today'] * 100,
+ 2
+ );
+ } else {
+ $aggregate['summary']['ads_percentage_today'] = 0;
+ }
+
+ $status = 'unknown';
+ if ($statuses) {
+ $allEnabled = count(array_filter($statuses, fn($s) => $s === 'enabled')) === count($statuses);
+ $allDisabled = count(array_filter($statuses, fn($s) => $s === 'disabled')) === count($statuses);
+ if ($allEnabled) {
+ $status = 'enabled';
+ } elseif ($allDisabled) {
+ $status = 'disabled';
+ } else {
+ $status = 'partial';
+ }
+ }
+ $aggregate['summary']['status'] = $status;
+
+ $debugPush('dashboard.response', [
+ 'aggregate_summary' => $aggregate['summary'],
+ 'instance_ids' => array_keys($instancePayloads),
+ ]);
+
+ $respond([
+ 'ok' => true,
+ 'ts' => time(),
+ 'instances' => $instancePayloads,
+ 'aggregate' => $aggregate,
+ ]);
+}
+
+if ($action === 'test') {
+ require_admin();
+ $target = (string)($payload['instance'] ?? '');
+ if ($target === '') {
+ $respond(['ok' => false, 'error' => 'missing_instance'], 400);
+ }
+ if (!isset($instances[$target])) {
+ $respond(['ok' => false, 'error' => 'invalid_instance'], 400);
+ }
+
+ $instance = $instances[$target];
+ $debugPush('test.request', [
+ 'instance_id' => $target,
+ 'instance_name' => (string)($instance['name'] ?? $target),
+ 'url' => (string)($instance['url'] ?? ''),
+ ]);
+ $apiInfo = $detectApi($instance);
+ $debugPush('test.detect.result', [
+ 'instance_id' => $target,
+ 'version' => (int)($apiInfo['version'] ?? 0),
+ 'sid_present' => !empty($apiInfo['sid']),
+ ]);
+ if (!empty($apiInfo['probe']) && is_array($apiInfo['probe'])) {
+ $debugResult('test.probe', $target, $instance, $apiInfo['probe']);
+ }
+ if ($apiInfo['version'] === 6) {
+ $result = $apiInfo['probe'];
+ if ($result['ok']) {
+ $respond([
+ 'ok' => true,
+ 'status' => 'ok',
+ 'message' => 'Verbindung OK. API v6 antwortet.',
+ ]);
+ }
+ $httpCode = (int)($result['http_code'] ?? 0);
+ $error = (string)($result['error'] ?? 'error');
+ $status = 'error';
+ $message = 'Unbekannter Fehler.';
+
+ if ($httpCode === 0) {
+ $status = 'unreachable';
+ $message = 'Host nicht erreichbar oder kein HTTP-Response.';
+ } elseif ($httpCode === 401 || $httpCode === 403) {
+ $status = 'auth';
+ $message = 'Passwort oder App-Passwort falsch oder nicht berechtigt.';
+ } elseif ($error === 'invalid_json') {
+ $status = 'invalid';
+ $message = 'API antwortet nicht mit JSON. URL pruefen.';
+ } else {
+ $status = 'error';
+ $message = 'API Fehler: ' . $error . ' (HTTP ' . $httpCode . ')';
+ }
+
+ $respond([
+ 'ok' => false,
+ 'status' => $status,
+ 'message' => $message,
+ 'http_code' => $httpCode,
+ 'error' => $error,
+ 'url' => (string)($result['url'] ?? ''),
+ ]);
+ }
+
+ $result = $v5Request($instance, ['summaryRaw' => 1]);
+ if ($result['ok']) {
+ $respond([
+ 'ok' => true,
+ 'status' => 'ok',
+ 'message' => 'Verbindung OK. API v5 antwortet.',
+ ]);
+ }
+
+ $httpCode = (int)($result['http_code'] ?? 0);
+ $error = (string)($result['error'] ?? 'error');
+ $status = 'error';
+ $message = 'Unbekannter Fehler.';
+
+ if ($httpCode === 0) {
+ $status = 'unreachable';
+ $message = 'Host nicht erreichbar oder kein HTTP-Response.';
+ } elseif ($httpCode === 401 || $httpCode === 403) {
+ $status = 'auth';
+ $message = 'Passwort oder Legacy-API-Token falsch oder nicht berechtigt.';
+ } elseif ($error === 'invalid_json') {
+ $status = 'invalid';
+ $message = 'API antwortet nicht mit JSON. URL oder API-Pfad pruefen.';
+ } else {
+ $status = 'error';
+ $message = 'API Fehler: ' . $error . ' (HTTP ' . $httpCode . ')';
+ }
+
+ $respond([
+ 'ok' => false,
+ 'status' => $status,
+ 'message' => $message,
+ 'http_code' => $httpCode,
+ 'error' => $error,
+ 'url' => (string)($result['url'] ?? ''),
+ ]);
+}
+
+if ($action === 'disable') {
+ require_admin();
+ $debugPush('disable.request', ['payload' => $payload]);
+ $minutes = (int)($payload['minutes'] ?? 0);
+ $target = (string)($payload['instance'] ?? 'all');
+ if ($minutes <= 0) {
+ $respond(['ok' => false, 'error' => 'invalid_minutes'], 400);
+ }
+ $targets = $pickInstances($target);
+ if (!$targets) {
+ $respond(['ok' => false, 'error' => 'invalid_instance'], 400);
+ }
+
+ $results = [];
+ foreach ($targets as $id => $instance) {
+ $apiInfo = $detectApi($instance);
+ if ($apiInfo['version'] === 6) {
+ $sid = (string)($apiInfo['sid'] ?? '');
+ $result = $v6Request($instance, 'dns/blocking', 'POST', [
+ 'blocking' => false,
+ 'timer' => $minutes * 60,
+ ], $sid);
+ } elseif ($apiInfo['version'] === 5) {
+ $result = $v5Request($instance, ['disable' => $minutes * 60]);
+ } else {
+ $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => ''];
+ }
+ $results[$id] = $result;
+ $debugResult('disable.result', $id, $instance, $result);
+ }
+ $respond(['ok' => true, 'results' => $results]);
+}
+
+if ($action === 'enable') {
+ require_admin();
+ $debugPush('enable.request', ['payload' => $payload]);
+ $target = (string)($payload['instance'] ?? 'all');
+ $targets = $pickInstances($target);
+ if (!$targets) {
+ $respond(['ok' => false, 'error' => 'invalid_instance'], 400);
+ }
+
+ $results = [];
+ foreach ($targets as $id => $instance) {
+ $apiInfo = $detectApi($instance);
+ if ($apiInfo['version'] === 6) {
+ $sid = (string)($apiInfo['sid'] ?? '');
+ $result = $v6Request($instance, 'dns/blocking', 'POST', [
+ 'blocking' => true,
+ ], $sid);
+ } elseif ($apiInfo['version'] === 5) {
+ $result = $v5Request($instance, ['enable' => 1]);
+ } else {
+ $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => ''];
+ }
+ $results[$id] = $result;
+ $debugResult('enable.result', $id, $instance, $result);
+ }
+ $respond(['ok' => true, 'results' => $results]);
+}
+
+if ($action === 'gravity') {
+ require_admin();
+ $debugPush('gravity.request', ['payload' => $payload]);
+ $actionTimeout = $resolveActionTimeout();
+ $target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary');
+ $targets = $pickInstances($target);
+ if (!$targets) {
+ $respond(['ok' => false, 'error' => 'invalid_instance'], 400);
+ }
+ $results = [];
+ foreach ($targets as $id => $instance) {
+ $apiInfo = $detectApi($instance);
+ if ($apiInfo['version'] === 6) {
+ $sid = (string)($apiInfo['sid'] ?? '');
+ $result = $v6Request($instance, 'action/gravity', 'POST', [], $sid, $actionTimeout);
+ } elseif ($apiInfo['version'] === 5) {
+ $result = $v5Request($instance, ['updateGravity' => 1], $actionTimeout);
+ } else {
+ $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => ''];
+ }
+ $results[$id] = $result;
+ $debugResult('gravity.result', $id, $instance, $result);
+ }
+ $respond(['ok' => true, 'results' => $results]);
+}
+
+if ($action === 'domain_add') {
+ require_admin();
+ $debugPush('domain_add.request', ['payload' => $payload]);
+ $domain = trim((string)($payload['domain'] ?? ''));
+ $type = (string)($payload['type'] ?? 'block');
+ if ($domain === '') {
+ $respond(['ok' => false, 'error' => 'missing_domain'], 400);
+ }
+ $target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary');
+ $targets = $pickInstances($target);
+ if (!$targets) {
+ $respond(['ok' => false, 'error' => 'invalid_instance'], 400);
+ }
+
+ $params = $type === 'allow'
+ ? ['list' => 'white', 'add' => $domain]
+ : ['list' => 'black', 'add' => $domain];
+
+ $results = [];
+ foreach ($targets as $id => $instance) {
+ $apiInfo = $detectApi($instance);
+ if ($apiInfo['version'] === 6) {
+ $result = ['ok' => false, 'error' => 'not_supported_v6', 'http_code' => 400, 'url' => ''];
+ } elseif ($apiInfo['version'] === 5) {
+ $result = $v5Request($instance, $params);
+ } else {
+ $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => ''];
+ }
+ $results[$id] = $result;
+ $debugResult('domain_add.result', $id, $instance, $result);
+ }
+ $respond(['ok' => true, 'results' => $results]);
+}
+
+if ($action === 'adlist_add') {
+ require_admin();
+ $debugPush('adlist_add.request', ['payload' => $payload]);
+ $url = trim((string)($payload['url'] ?? ''));
+ if ($url === '') {
+ $respond(['ok' => false, 'error' => 'missing_url'], 400);
+ }
+ $target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary');
+ $targets = $pickInstances($target);
+ if (!$targets) {
+ $respond(['ok' => false, 'error' => 'invalid_instance'], 400);
+ }
+
+ $results = [];
+ foreach ($targets as $id => $instance) {
+ $apiInfo = $detectApi($instance);
+ if ($apiInfo['version'] === 6) {
+ $result = ['ok' => false, 'error' => 'not_supported_v6', 'http_code' => 400, 'url' => ''];
+ } elseif ($apiInfo['version'] === 5) {
+ $result = $v5Request($instance, ['list' => 'adlist', 'add' => $url]);
+ } else {
+ $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => ''];
+ }
+ $results[$id] = $result;
+ $debugResult('adlist_add.result', $id, $instance, $result);
+ }
+ $respond(['ok' => true, 'results' => $results]);
+}
+
+if ($action === 'update') {
+ require_admin();
+ $debugPush('update.request', ['payload' => $payload]);
+ $actionTimeout = $resolveActionTimeout();
+ $target = (string)($payload['instance'] ?? 'primary');
+ $targets = $pickInstances($target);
+ if (!$targets) {
+ $respond(['ok' => false, 'error' => 'invalid_instance'], 400);
+ }
+
+ $results = [];
+ foreach ($targets as $id => $instance) {
+ $apiInfo = $detectApi($instance);
+ if ($apiInfo['version'] === 6) {
+ $result = ['ok' => false, 'error' => 'not_supported_v6', 'http_code' => 400, 'url' => ''];
+ } elseif ($apiInfo['version'] === 5) {
+ $result = $v5Request($instance, ['update' => 1], $actionTimeout);
+ } else {
+ $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => ''];
+ }
+ $results[$id] = $result;
+ $debugResult('update.result', $id, $instance, $result);
+ }
+ $respond(['ok' => true, 'results' => $results]);
+}
+
+$respond(['ok' => false, 'error' => 'unknown_action'], 400);
diff --git a/modules/pihole/assets/pihole-native.js b/modules/pihole/assets/pihole-native.js
new file mode 100644
index 00000000..8df52ffa
--- /dev/null
+++ b/modules/pihole/assets/pihole-native.js
@@ -0,0 +1,121 @@
+(function () {
+ 'use strict';
+
+ function toPartialUrl(input, fallbackView) {
+ const url = new URL(input, window.location.origin);
+ if (!url.searchParams.has('view') && fallbackView) {
+ url.searchParams.set('view', fallbackView);
+ }
+ url.searchParams.set('partial', '1');
+ return url;
+ }
+
+ async function fetchHtml(url, options) {
+ const response = await fetch(url.toString(), {
+ credentials: 'same-origin',
+ headers: {
+ Accept: 'text/html',
+ 'X-Requested-With': 'XMLHttpRequest'
+ },
+ ...options
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ return response.text();
+ }
+
+ function enhanceHost(host, state) {
+ host.querySelectorAll('a[href]').forEach((link) => {
+ const href = link.getAttribute('href') || '';
+ if (!href.startsWith('/apps/pihole')) {
+ return;
+ }
+
+ link.addEventListener('click', (event) => {
+ event.preventDefault();
+ state.load(href);
+ });
+ });
+
+ host.querySelectorAll('form').forEach((form) => {
+ form.addEventListener('submit', async (event) => {
+ event.preventDefault();
+
+ const method = String(form.getAttribute('method') || 'get').toUpperCase();
+ const action = form.getAttribute('action') || state.currentUrl.toString();
+ const formData = new FormData(form);
+ const url = toPartialUrl(action, state.defaultView);
+
+ try {
+ if (method === 'GET') {
+ const next = new URL(url.toString());
+ next.search = '';
+ next.searchParams.set('partial', '1');
+ for (const [key, value] of formData.entries()) {
+ next.searchParams.append(key, String(value));
+ }
+ await state.load(next.toString());
+ return;
+ }
+
+ const html = await fetchHtml(url, {
+ method,
+ body: formData
+ });
+ state.render(html, url);
+ } catch (error) {
+ state.renderError(error);
+ }
+ });
+ });
+ }
+
+ function initContent(host) {
+ if (typeof window.initPiholeModule === 'function') {
+ window.initPiholeModule(host);
+ }
+ if (typeof window.initPiholeInstancesModule === 'function') {
+ window.initPiholeInstancesModule(host);
+ }
+ }
+
+ window.initPiholeApp = function initPiholeApp(host, options) {
+ if (!(host instanceof HTMLElement)) {
+ return;
+ }
+
+ const entryRoute = String(options?.entryRoute || '/apps/pihole/index.php');
+ const defaultView = String(options?.defaultView || 'dashboard');
+
+ const state = {
+ defaultView,
+ currentUrl: toPartialUrl(entryRoute, defaultView),
+ async load(targetUrl) {
+ const url = toPartialUrl(targetUrl, defaultView);
+ this.currentUrl = url;
+ host.innerHTML = '
';
+ try {
+ const html = await fetchHtml(url);
+ this.render(html, url);
+ } catch (error) {
+ this.renderError(error);
+ }
+ },
+ render(html, url) {
+ this.currentUrl = url;
+ host.innerHTML = html;
+ enhanceHost(host, this);
+ initContent(host);
+ },
+ renderError(error) {
+ const message = error instanceof Error ? error.message : 'Pi-hole konnte nicht geladen werden.';
+ host.innerHTML = ``;
+ }
+ };
+
+ state.load(entryRoute);
+ };
+})();
diff --git a/modules/pihole/assets/pihole.css b/modules/pihole/assets/pihole.css
new file mode 100644
index 00000000..4320e456
--- /dev/null
+++ b/modules/pihole/assets/pihole.css
@@ -0,0 +1,391 @@
+.pihole-frame {
+ height: 100%;
+ min-height: 0;
+}
+
+.pihole-panel-shell {
+ max-width: 1280px;
+}
+
+.pihole-content {
+ display: grid;
+ gap: 18px;
+}
+
+.pihole-card,
+.module-box,
+.module-box-soft {
+ border: 1px solid rgba(148, 163, 184, 0.24);
+ border-radius: 22px;
+ background: rgba(255, 255, 255, 0.92);
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
+ padding: 20px;
+}
+
+.pihole-message {
+ padding: 14px 16px;
+ border-radius: 16px;
+}
+
+.pihole-message--error {
+ color: #991b1b;
+ background: rgba(254, 226, 226, 0.9);
+ border: 1px solid rgba(248, 113, 113, 0.3);
+}
+
+.pihole-message--success {
+ color: #166534;
+ background: rgba(220, 252, 231, 0.9);
+ border: 1px solid rgba(74, 222, 128, 0.3);
+}
+
+.pihole-grid,
+.module-box-grid--stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 14px;
+}
+
+.pihole-stat {
+ padding: 16px;
+}
+
+.pihole-stat-value {
+ font-size: 1.6rem;
+ font-weight: 700;
+ margin-top: 6px;
+}
+
+.pihole-stat-sub {
+ margin-top: 4px;
+ color: #475569;
+}
+
+.pihole-section-header,
+.module-box-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.pihole-section-title,
+.module-box-title {
+ margin: 0;
+ font-size: 1.2rem;
+ line-height: 1.2;
+}
+
+.pihole-actions,
+.pihole-card-actions,
+.pihole-modal-actions {
+ margin-top: 12px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+
+.pihole-primary-action,
+.cta-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 44px;
+ padding: 10px 16px;
+ border: 0;
+ border-radius: 14px;
+ background: linear-gradient(135deg, #14b8a6, #0f766e);
+ color: #fff;
+ font: inherit;
+ font-weight: 700;
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.pihole-link-button,
+.nav-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 40px;
+ padding: 8px 14px;
+ border: 1px solid rgba(148, 163, 184, 0.24);
+ border-radius: 14px;
+ background: rgba(255, 255, 255, 0.92);
+ color: inherit;
+ font: inherit;
+ font-weight: 600;
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.pihole-inline {
+ display: inline-flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.pihole-inline input {
+ width: 110px;
+}
+
+.pihole-instance-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 14px;
+ margin-top: 12px;
+}
+
+.pihole-instance,
+.pihole-instance-card {
+ display: grid;
+ gap: 12px;
+}
+
+.pihole-instance-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+}
+
+.pihole-instance-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 10px;
+ margin-top: 12px;
+}
+
+.pihole-instance-value {
+ font-weight: 700;
+ margin-top: 4px;
+}
+
+.pihole-status {
+ padding: 6px 10px;
+ border-radius: 999px;
+ font-size: 0.85rem;
+ font-weight: 600;
+ background: rgba(20, 184, 166, 0.15);
+ color: #0f766e;
+}
+
+.pihole-status.is-disabled {
+ background: rgba(239, 68, 68, 0.15);
+ color: #b91c1c;
+}
+
+.pihole-status.is-partial {
+ background: rgba(245, 158, 11, 0.18);
+ color: #92400e;
+}
+
+.pihole-split,
+.module-box-grid--panels {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 14px;
+}
+
+.pihole-list,
+.pihole-blocked {
+ display: grid;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.pihole-list-row,
+.pihole-blocked-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 8px 10px;
+ border-radius: 12px;
+ background: rgba(248, 250, 252, 0.95);
+}
+
+.pihole-blocked-row {
+ background: rgba(254, 242, 242, 0.9);
+}
+
+.pihole-update {
+ margin-top: 10px;
+ font-size: 0.95rem;
+ color: #475569;
+}
+
+.pihole-error {
+ margin-top: 8px;
+ font-size: 0.9rem;
+ color: #b91c1c;
+}
+
+.pihole-form,
+.pihole-setup-form {
+ display: grid;
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.pihole-form {
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ align-items: end;
+}
+
+.pihole-form label,
+.pihole-field {
+ display: grid;
+ gap: 6px;
+}
+
+.pihole-field input,
+.pihole-field select,
+.pihole-form input,
+.pihole-form select,
+.pihole-form textarea,
+.pihole-instance-form input {
+ width: 100%;
+ min-height: 44px;
+ padding: 10px 12px;
+ border: 1px solid rgba(148, 163, 184, 0.24);
+ border-radius: 14px;
+ background: #fff;
+ color: #0f172a;
+ font: inherit;
+}
+
+.pihole-setup-grid,
+.pihole-instance-form-grid,
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 12px;
+}
+
+.pihole-form-field-wide {
+ grid-column: 1 / -1;
+}
+
+.pihole-checkbox-field {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.pihole-checkbox-field input {
+ width: 18px;
+ min-height: 18px;
+}
+
+.pihole-page.is-busy {
+ position: relative;
+}
+
+.modal {
+ position: fixed;
+ inset: 0;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ background: rgba(15, 23, 42, 0.24);
+ z-index: 90;
+}
+
+.modal.is-open,
+.modal[aria-hidden="false"] {
+ display: flex;
+}
+
+.modal-card {
+ width: min(720px, 96vw);
+ border-radius: 18px;
+ border: 1px solid rgba(148, 163, 184, 0.24);
+ background: rgba(255, 255, 255, 0.98);
+ box-shadow: 0 28px 64px rgba(15, 23, 42, 0.18);
+ padding: 20px;
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.icon-button {
+ border: 1px solid rgba(148, 163, 184, 0.24);
+ border-radius: 12px;
+ background: #fff;
+ color: #0f172a;
+ width: 40px;
+ height: 40px;
+ cursor: pointer;
+}
+
+.pihole-console-body {
+ margin-top: 16px;
+ display: grid;
+ gap: 10px;
+ max-height: 48vh;
+ overflow: auto;
+ padding: 4px;
+}
+
+.pihole-console-line {
+ display: grid;
+ gap: 4px;
+ padding: 12px 14px;
+ border-radius: 14px;
+ border: 1px solid rgba(148, 163, 184, 0.24);
+ background: rgba(255, 255, 255, 0.75);
+}
+
+.pihole-console-line span {
+ font-size: 0.8rem;
+ color: #475569;
+}
+
+.pihole-console-line strong {
+ font-weight: 600;
+}
+
+.pihole-console-line.is-success {
+ border-color: rgba(20, 184, 166, 0.22);
+ background: rgba(20, 184, 166, 0.08);
+}
+
+.pihole-console-line.is-error {
+ border-color: rgba(239, 68, 68, 0.24);
+ background: rgba(239, 68, 68, 0.08);
+}
+
+.pihole-test-result {
+ margin-top: 6px;
+ font-size: 0.9rem;
+ color: #475569;
+}
+
+.pihole-test-result.is-ok { color: #0f766e; }
+.pihole-test-result.is-auth,
+.pihole-test-result.is-unreachable,
+.pihole-test-result.is-error { color: #b91c1c; }
+.pihole-test-result.is-invalid { color: #92400e; }
+
+@media (max-width: 980px) {
+ .pihole-frame {
+ grid-template-columns: 1fr;
+ }
+
+ .pihole-actions,
+ .pihole-card-actions,
+ .pihole-modal-actions,
+ .pihole-inline {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .pihole-inline input {
+ width: 100%;
+ }
+}
diff --git a/modules/pihole/assets/pihole.js b/modules/pihole/assets/pihole.js
new file mode 100644
index 00000000..c92c0522
--- /dev/null
+++ b/modules/pihole/assets/pihole.js
@@ -0,0 +1,448 @@
+(function () {
+ 'use strict';
+
+ window.initPiholeModule = function initPiholeModule(rootNode) {
+ const scope = rootNode instanceof Element ? rootNode : document;
+ const page = scope.matches?.('[data-pihole-page]') ? scope : scope.querySelector('[data-pihole-page]');
+ if (!page || page.dataset.piholeBound === '1') return;
+ page.dataset.piholeBound = '1';
+
+ const pageType = page.dataset.piholePage || 'dashboard';
+ const configuredRefreshSeconds = Number(page.dataset.refreshSeconds || 0);
+ const defaultRefreshSeconds = pageType === 'dashboard' ? 1 : 5;
+ const refreshSeconds = Number.isFinite(configuredRefreshSeconds) && configuredRefreshSeconds >= 0
+ ? configuredRefreshSeconds
+ : defaultRefreshSeconds;
+
+ const fmt = new Intl.NumberFormat('de-DE');
+ const fmtDate = (ts) => {
+ if (!ts) return '–';
+ const d = new Date(ts * 1000);
+ return d.toLocaleString('de-DE');
+ };
+
+ let refreshTimer = null;
+ let loadInFlight = false;
+ let actionInFlight = false;
+ let actionConsoleApi = null;
+ let actionConsoleBody = null;
+ let actionConsoleClose = null;
+
+ const apiCall = async (action, payload = {}) => {
+ const res = await fetch(`/api/pihole/index.php?action=${encodeURIComponent(action)}`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(data.error || `HTTP ${res.status}`);
+ }
+ if (data && data.results && typeof data.results === 'object') {
+ const failures = Object.values(data.results).filter((row) => row && row.ok === false);
+ if (failures.length) {
+ const first = failures[0];
+ throw new Error(first.error || 'action_failed');
+ }
+ }
+ return data;
+ };
+
+ const query = (selector) => page.querySelector(selector);
+ const setText = (el, value) => {
+ if (el) el.textContent = value;
+ };
+
+ const ensureActionConsole = () => {
+ if (actionConsoleApi && actionConsoleBody && actionConsoleClose) return;
+
+ const root = document.createElement('div');
+ root.className = 'modal';
+ root.dataset.piholeConsoleModal = '1';
+ root.setAttribute('aria-hidden', 'true');
+ root.innerHTML = `
+
+ `;
+ document.body.appendChild(root);
+
+ actionConsoleBody = root.querySelector('[data-pihole-console-body]');
+ actionConsoleClose = root.querySelector('[data-pihole-console-close]');
+ const clearBtn = root.querySelector('[data-pihole-console-clear]');
+
+ actionConsoleApi = {
+ open() {
+ root.classList.add('is-open');
+ root.setAttribute('aria-hidden', 'false');
+ },
+ close() {
+ root.classList.remove('is-open');
+ root.setAttribute('aria-hidden', 'true');
+ }
+ };
+
+ actionConsoleClose?.addEventListener('click', () => {
+ if (!actionInFlight) actionConsoleApi.close();
+ });
+
+ clearBtn?.addEventListener('click', () => {
+ if (actionConsoleBody) actionConsoleBody.innerHTML = '';
+ });
+ };
+
+ const appendActionLog = (message, tone = 'info') => {
+ ensureActionConsole();
+ if (!actionConsoleBody) return;
+ const row = document.createElement('div');
+ row.className = `pihole-console-line is-${tone}`;
+ row.innerHTML = `${new Date().toLocaleTimeString('de-DE')}${message}`;
+ actionConsoleBody.appendChild(row);
+ actionConsoleBody.scrollTop = actionConsoleBody.scrollHeight;
+ };
+
+ const setActionLock = (locked) => {
+ actionInFlight = locked;
+ page.classList.toggle('is-busy', locked);
+ ensureActionConsole();
+ if (actionConsoleClose) {
+ actionConsoleClose.disabled = locked;
+ }
+
+ page.querySelectorAll('button, input, select, textarea').forEach((formControl) => {
+ if (locked) {
+ formControl.dataset.piholeWasDisabled = formControl.disabled ? 'true' : 'false';
+ formControl.disabled = true;
+ return;
+ }
+ formControl.disabled = formControl.dataset.piholeWasDisabled === 'true';
+ delete formControl.dataset.piholeWasDisabled;
+ });
+ };
+
+ const statusLabel = (status) => {
+ if (status === 'enabled') return 'Aktiv';
+ if (status === 'disabled') return 'Deaktiviert';
+ if (status === 'partial') return 'Teilweise';
+ return 'Unbekannt';
+ };
+
+ const setStatusBadge = (el, status) => {
+ if (!el) return;
+ el.textContent = statusLabel(status);
+ el.classList.remove('is-disabled', 'is-partial');
+ if (status === 'disabled') el.classList.add('is-disabled');
+ if (status === 'partial') el.classList.add('is-partial');
+ };
+
+ const mapToList = (map, limit = 10) => {
+ if (!map || typeof map !== 'object') return [];
+ return Object.entries(map)
+ .filter(([, value]) => typeof value === 'number' || typeof value === 'string')
+ .map(([key, value]) => [key, Number(value)])
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, limit);
+ };
+
+ const renderList = (container, map, emptyText) => {
+ if (!container) return;
+ const rows = mapToList(map, 10);
+ container.innerHTML = '';
+ if (!rows.length) {
+ const div = document.createElement('div');
+ div.className = 'muted';
+ div.textContent = emptyText || 'Keine Daten';
+ container.appendChild(div);
+ return;
+ }
+ rows.forEach(([label, value]) => {
+ const row = document.createElement('div');
+ row.className = 'pihole-list-row';
+ row.innerHTML = `${label}${fmt.format(value)}`;
+ container.appendChild(row);
+ });
+ };
+
+ const renderBlocked = (container, list) => {
+ if (!container) return;
+ container.innerHTML = '';
+ if (!Array.isArray(list) || !list.length) {
+ const div = document.createElement('div');
+ div.className = 'muted';
+ div.textContent = 'Keine Daten';
+ container.appendChild(div);
+ return;
+ }
+ list.slice(0, 20).forEach((entry) => {
+ const domain = entry.domain || entry;
+ const instance = entry.instance || '';
+ const row = document.createElement('div');
+ row.className = 'pihole-blocked-row';
+ row.innerHTML = `${domain}${instance}`;
+ container.appendChild(row);
+ });
+ };
+
+ const renderInstances = (instances) => {
+ const holder = query('[data-instance-cards]');
+ const tpl = scope.querySelector('#pihole-instance-template');
+ if (!holder || !tpl) return;
+ holder.innerHTML = '';
+
+ Object.values(instances).forEach((entry) => {
+ const node = tpl.content.cloneNode(true);
+ const root = node.querySelector('[data-instance]');
+ if (!root) return;
+ root.dataset.instance = entry.meta.id;
+
+ const summary = entry.summary || {};
+ setText(root.querySelector('[data-instance-name]'), entry.meta.name || entry.meta.id);
+ setText(root.querySelector('[data-instance-url]'), entry.meta.url || '');
+ setText(root.querySelector('[data-instance-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
+ setText(root.querySelector('[data-instance-ads]'), fmt.format(Number(summary.ads_blocked_today || 0)));
+ setText(root.querySelector('[data-instance-percent]'), `${Number(summary.ads_percentage_today || summary.ads_percentage || 0).toFixed(2)}%`);
+ setStatusBadge(root.querySelector('[data-instance-status]'), summary.status || 'unknown');
+
+ root.querySelectorAll('[data-instance]').forEach((btn) => {
+ if (btn.dataset.instance === '') btn.dataset.instance = entry.meta.id;
+ });
+ const customInput = root.querySelector('[data-custom-minutes]');
+ if (customInput) customInput.dataset.customMinutes = entry.meta.id;
+
+ const updateEl = root.querySelector('[data-instance-update]');
+ if (updateEl) {
+ updateEl.textContent = entry.updates && entry.updates.available ? 'Updates verfuegbar' : 'Keine Updates erkannt';
+ }
+
+ const errorEl = root.querySelector('[data-instance-errors]');
+ if (errorEl) {
+ if (Array.isArray(entry.errors) && entry.errors.length) {
+ const lines = entry.errors.map((err) => {
+ const code = err.http_code ? `HTTP ${err.http_code}` : 'HTTP ?';
+ const scopeName = err.scope || 'request';
+ const msg = err.error || 'error';
+ const hint = err.hint ? `, Hint: ${typeof err.hint === 'string' ? err.hint : JSON.stringify(err.hint)}` : '';
+ return `${scopeName}: ${msg} (${code}${hint})`;
+ });
+ errorEl.textContent = `API Fehler: ${lines.join(' | ')}`;
+ } else {
+ errorEl.textContent = '';
+ }
+ }
+
+ holder.appendChild(node);
+ });
+ };
+
+ const renderDashboardData = (data) => {
+ const summary = data.aggregate?.summary || {};
+ setText(query('[data-summary-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
+ setText(query('[data-summary-blocked]'), fmt.format(Number(summary.ads_blocked_today || 0)));
+ setText(query('[data-summary-percent]'), `${Number(summary.ads_percentage_today || 0).toFixed(2)}%`);
+ setText(query('[data-summary-clients]'), fmt.format(Number(summary.unique_clients || 0)));
+ setStatusBadge(query('[data-summary-status]'), summary.status || 'unknown');
+ setText(query('[data-summary-last-refresh]'), `Letztes Update: ${fmtDate(data.ts)}`);
+
+ renderInstances(data.instances || {});
+ renderList(query('[data-query-types]'), data.aggregate?.query_types, 'Keine Daten');
+ renderList(query('[data-forward-destinations]'), data.aggregate?.forward_destinations, 'Keine Daten');
+ renderList(query('[data-top-ads]'), data.aggregate?.top_ads, 'Keine Daten');
+ renderList(query('[data-top-queries]'), data.aggregate?.top_queries, 'Keine Daten');
+ renderList(query('[data-top-clients]'), data.aggregate?.query_sources, 'Keine Daten');
+ renderBlocked(query('[data-recent-blocked]'), data.aggregate?.recent_blocked);
+ };
+
+ const dashboardFingerprint = (data) => JSON.stringify({
+ blocked_domains: Number(data?.aggregate?.summary?.blocked_domains || 0),
+ ads_blocked_today: Number(data?.aggregate?.summary?.ads_blocked_today || 0),
+ unique_domains: Number(data?.aggregate?.summary?.unique_domains || 0),
+ instances: Object.values(data?.instances || {}).map((entry) => ({
+ id: entry?.meta?.id || '',
+ blocked_domains: Number(entry?.summary?.blocked_domains || 0),
+ ads_blocked_today: Number(entry?.summary?.ads_blocked_today || 0),
+ unique_domains: Number(entry?.summary?.unique_domains || 0),
+ })),
+ });
+
+ const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
+
+ const monitorGravityProgress = async (baselineData, instanceLabel) => {
+ const baselineFingerprint = dashboardFingerprint(baselineData || {});
+ const maxPolls = 24;
+ const pollDelayMs = 5000;
+
+ appendActionLog(`Pi-hole meldet keinen Live-Fortschritt. Pruefe jetzt zyklisch auf erkennbare Aenderungen fuer ${instanceLabel}.`, 'info');
+
+ for (let attempt = 1; attempt <= maxPolls; attempt += 1) {
+ await delay(pollDelayMs);
+ appendActionLog(`Pruefung ${attempt}/${maxPolls}: aktuelle Statusdaten werden geladen ...`, 'info');
+ try {
+ const data = await apiCall('dashboard');
+ if (!data.ok) throw new Error(data.error || 'API error');
+ renderDashboardData(data);
+ if (dashboardFingerprint(data) !== baselineFingerprint) {
+ appendActionLog(`Erkennbare Aenderung gefunden. Listen-Update fuer ${instanceLabel} scheint abgeschlossen zu sein.`, 'success');
+ return true;
+ }
+ } catch (err) {
+ appendActionLog(`Statuspruefung fehlgeschlagen: ${err.message}`, 'error');
+ }
+ }
+
+ appendActionLog(`Innerhalb des Beobachtungsfensters wurde keine erkennbare Aenderung gefunden.`, 'error');
+ return false;
+ };
+
+ const loadDashboard = async () => {
+ if (loadInFlight || actionInFlight) return;
+ loadInFlight = true;
+ try {
+ const data = await apiCall('dashboard');
+ if (!data.ok) throw new Error(data.error || 'API error');
+ renderDashboardData(data);
+ query('[data-pihole-load-error]')?.remove();
+ } catch (err) {
+ let message = query('[data-pihole-load-error]');
+ if (!message) {
+ message = document.createElement('div');
+ message.className = 'window-app-card pihole-card';
+ message.style.marginTop = '1rem';
+ message.dataset.piholeLoadError = '1';
+ page.appendChild(message);
+ }
+ message.textContent = `Fehler beim Laden der Pi-hole Daten: ${err.message}`;
+ } finally {
+ loadInFlight = false;
+ }
+ };
+
+ page.addEventListener('click', async (e) => {
+ const btn = e.target.closest('[data-action]');
+ if (!btn) return;
+
+ const action = btn.dataset.action;
+ const instance = btn.dataset.instance || 'all';
+ const minutes = btn.dataset.minutes;
+ const buttonScope = btn.closest('.pihole-instance') || page;
+ const customInput = buttonScope.querySelector(`[data-custom-minutes="${instance}"]`);
+ const payload = { instance };
+ let baselineData = null;
+
+ if (action === 'disable') payload.minutes = Number(minutes || 0);
+ if (action === 'disable-custom') payload.minutes = Number(customInput?.value || 0);
+
+ try {
+ ensureActionConsole();
+ if (actionConsoleBody) actionConsoleBody.innerHTML = '';
+ actionConsoleApi.open();
+ setActionLock(true);
+
+ baselineData = action === 'gravity' ? await apiCall('dashboard').catch(() => null) : null;
+
+ if (action === 'enable') {
+ appendActionLog(`Aktiviere ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info');
+ await apiCall('enable', payload);
+ } else if (action === 'disable' || action === 'disable-custom') {
+ if (!payload.minutes || payload.minutes <= 0) {
+ appendActionLog('Fehler: Bitte Minuten angeben.', 'error');
+ return;
+ }
+ appendActionLog(`Deaktiviere ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`} fuer ${payload.minutes} Minuten.`, 'info');
+ await apiCall('disable', payload);
+ } else if (action === 'gravity') {
+ appendActionLog(`Starte Listen-Update fuer ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info');
+ await apiCall('gravity', payload);
+ const status = query('[data-list-update-status]');
+ if (status) status.textContent = 'Listen-Update gestartet.';
+ } else if (action === 'update') {
+ appendActionLog(`Starte Pi-hole-Update fuer ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info');
+ await apiCall('update', payload);
+ }
+
+ appendActionLog('Aktion abgeschlossen. Dashboard wird aktualisiert.', 'success');
+ if (action === 'gravity') {
+ await monitorGravityProgress(baselineData, instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`);
+ } else {
+ await loadDashboard();
+ }
+ } catch (err) {
+ appendActionLog(`Aktion fehlgeschlagen: ${err.message}`, 'error');
+ } finally {
+ setActionLock(false);
+ }
+ });
+
+ const domainForm = query('[data-domain-form]');
+ domainForm?.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const formData = new FormData(domainForm);
+ const type = String(formData.get('type') || 'block');
+ const domain = String(formData.get('domain') || '').trim();
+ const status = query('[data-domain-status]');
+ if (!domain) return;
+ try {
+ await apiCall('domain_add', { type, domain, instance: 'primary' });
+ if (status) status.textContent = `Domain hinzugefuegt: ${domain}`;
+ domainForm.reset();
+ } catch (err) {
+ if (status) status.textContent = `Fehler: ${err.message}`;
+ }
+ });
+
+ const adlistForm = query('[data-adlist-form]');
+ adlistForm?.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const formData = new FormData(adlistForm);
+ const url = String(formData.get('url') || '').trim();
+ const status = query('[data-adlist-status]');
+ if (!url) return;
+ try {
+ await apiCall('adlist_add', { url, instance: 'primary' });
+ if (status) status.textContent = `Adlist hinzugefuegt: ${url}`;
+ adlistForm.reset();
+ } catch (err) {
+ if (status) status.textContent = `Fehler: ${err.message}`;
+ }
+ });
+
+ const stopAutoRefresh = () => {
+ if (refreshTimer !== null) {
+ window.clearInterval(refreshTimer);
+ refreshTimer = null;
+ }
+ };
+
+ const startAutoRefresh = () => {
+ stopAutoRefresh();
+ if (refreshSeconds <= 0 || document.visibilityState !== 'visible') return;
+ refreshTimer = window.setInterval(loadDashboard, refreshSeconds * 1000);
+ };
+
+ loadDashboard();
+ startAutoRefresh();
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') {
+ loadDashboard();
+ startAutoRefresh();
+ } else {
+ stopAutoRefresh();
+ }
+ });
+ window.addEventListener('beforeunload', stopAutoRefresh);
+ };
+
+ if (document.querySelector('[data-pihole-page]')) {
+ window.initPiholeModule(document);
+ }
+})();
diff --git a/modules/pihole/assets/pihole_instances.js b/modules/pihole/assets/pihole_instances.js
new file mode 100644
index 00000000..81b54862
--- /dev/null
+++ b/modules/pihole/assets/pihole_instances.js
@@ -0,0 +1,121 @@
+(function () {
+ 'use strict';
+
+ window.initPiholeInstancesModule = function initPiholeInstancesModule(rootNode) {
+ const scope = rootNode instanceof Element ? rootNode : document;
+ const modal = scope.querySelector('[data-instance-modal]');
+ const form = scope.querySelector('[data-instance-form]');
+
+ scope.querySelectorAll('[data-instance-test]').forEach((button) => {
+ if (button.dataset.piholeBound === '1') return;
+ button.dataset.piholeBound = '1';
+ button.addEventListener('click', async (event) => {
+ event.preventDefault();
+ const card = button.closest('[data-instance-id]');
+ if (!card) return;
+
+ const instanceId = card.dataset.instanceId || '';
+ const resultEl = card.querySelector('[data-instance-result]');
+ if (resultEl) {
+ resultEl.classList.remove('is-ok', 'is-auth', 'is-unreachable', 'is-invalid', 'is-error');
+ resultEl.textContent = 'Teste Verbindung...';
+ }
+
+ try {
+ const res = await fetch(`/api/pihole/index.php?action=test`, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ instance: instanceId }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
+ if (resultEl) {
+ resultEl.classList.add(data.status ? `is-${data.status}` : 'is-ok');
+ resultEl.textContent = data.message || 'Verbindung OK.';
+ }
+ } catch (err) {
+ if (resultEl) {
+ resultEl.classList.add('is-error');
+ resultEl.textContent = `Test fehlgeschlagen: ${err.message || 'Fehler'}`;
+ }
+ }
+ });
+ });
+
+ if (!modal || !form || modal.dataset.piholeBound === '1') return;
+ modal.dataset.piholeBound = '1';
+
+ const title = scope.querySelector('[data-instance-modal-title]');
+ const closeBtn = scope.querySelector('[data-instance-close]');
+ const newBtn = scope.querySelector('[data-instance-new]');
+ const cancelBtn = scope.querySelector('[data-instance-cancel]');
+ const currentIdInput = form.querySelector('input[name="current_id"]');
+ const idInput = form.querySelector('input[name="instance_id"]');
+ const nameInput = form.querySelector('input[name="name"]');
+ const urlInput = form.querySelector('input[name="url"]');
+ const passwordInput = form.querySelector('input[name="password"]');
+ const primaryInput = form.querySelector('input[name="is_primary"]');
+
+ const resetForm = () => {
+ if (currentIdInput) currentIdInput.value = '';
+ if (idInput) idInput.value = '';
+ if (nameInput) nameInput.value = '';
+ if (urlInput) urlInput.value = '';
+ if (passwordInput) passwordInput.value = '';
+ if (primaryInput) primaryInput.checked = false;
+ };
+
+ const openModal = () => {
+ modal.classList.add('is-open');
+ modal.setAttribute('aria-hidden', 'false');
+ if (idInput) window.setTimeout(() => idInput.focus(), 20);
+ };
+
+ const closeModal = () => {
+ modal.classList.remove('is-open');
+ modal.setAttribute('aria-hidden', 'true');
+ };
+
+ scope.querySelectorAll('[data-instance-edit]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const card = btn.closest('[data-instance-id]');
+ if (!card) return;
+ if (currentIdInput) currentIdInput.value = card.dataset.instanceId || '';
+ if (idInput) idInput.value = card.dataset.instanceId || '';
+ if (nameInput) nameInput.value = card.dataset.name || '';
+ if (urlInput) urlInput.value = card.dataset.url || '';
+ if (passwordInput) passwordInput.value = '';
+ if (primaryInput) primaryInput.checked = card.dataset.primary === '1';
+ if (title) title.textContent = 'Instanz bearbeiten';
+ openModal();
+ });
+ });
+
+ newBtn?.addEventListener('click', () => {
+ resetForm();
+ if (title) title.textContent = 'Neue Instanz';
+ openModal();
+ });
+
+ closeBtn?.addEventListener('click', closeModal);
+ cancelBtn?.addEventListener('click', () => {
+ resetForm();
+ closeModal();
+ });
+
+ modal.addEventListener('click', (event) => {
+ if (event.target === modal) closeModal();
+ });
+
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape' && modal.classList.contains('is-open')) {
+ closeModal();
+ }
+ });
+ };
+
+ if (document.querySelector('[data-instance-modal]')) {
+ window.initPiholeInstancesModule(document);
+ }
+})();
diff --git a/modules/pihole/bootstrap.php b/modules/pihole/bootstrap.php
new file mode 100644
index 00000000..a30feee9
--- /dev/null
+++ b/modules/pihole/bootstrap.php
@@ -0,0 +1,116 @@
+registerFunction($moduleName, 'settings', function () use ($moduleName): array {
+ return modules()->settings($moduleName);
+});
+
+$mm->registerFunction($moduleName, 'instances', function () use ($moduleName): array {
+ $settings = modules()->settings($moduleName);
+ $apiPath = trim((string)($settings['api_path'] ?? '/admin/api.php'));
+ if ($apiPath === '') {
+ $apiPath = '/admin/api.php';
+ }
+ if ($apiPath[0] !== '/') {
+ $apiPath = '/' . $apiPath;
+ }
+
+ $timeout = (int)($settings['api_timeout_sec'] ?? 8);
+ if ($timeout <= 0) {
+ $timeout = 8;
+ }
+
+ $verifyTls = !isset($settings['verify_tls']) || $settings['verify_tls'] === '1' || $settings['verify_tls'] === 1 || $settings['verify_tls'] === true;
+
+ $normalizeSecret = static function (array $row): string {
+ $password = trim((string)($row['password'] ?? ''));
+ if ($password !== '') {
+ return $password;
+ }
+
+ return trim((string)($row['token'] ?? ''));
+ };
+
+ $instances = [];
+
+ $rawJson = trim((string)($settings['instances_json'] ?? ''));
+ if ($rawJson !== '') {
+ $decoded = json_decode($rawJson, true);
+ if (is_array($decoded)) {
+ foreach ($decoded as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+ $id = trim((string)($row['id'] ?? ''));
+ $url = trim((string)($row['url'] ?? ''));
+ if ($id === '' || $url === '') {
+ continue;
+ }
+ $instances[$id] = [
+ 'id' => $id,
+ 'name' => trim((string)($row['name'] ?? '')) ?: $id,
+ 'url' => rtrim($url, '/'),
+ 'password' => $normalizeSecret($row),
+ 'api_path' => $apiPath,
+ 'timeout' => $timeout,
+ 'verify_tls' => $verifyTls,
+ 'is_primary' => !empty($row['is_primary']),
+ ];
+ }
+ }
+ }
+
+ if (empty($instances)) {
+ foreach (['primary', 'secondary'] as $key) {
+ $urlKey = $key . '_url';
+ $tokenKey = $key . '_token';
+ $nameKey = $key . '_name';
+ $url = trim((string)($settings[$urlKey] ?? ''));
+ if ($url === '') {
+ continue;
+ }
+ $instances[$key] = [
+ 'id' => $key,
+ 'name' => trim((string)($settings[$nameKey] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'),
+ 'url' => rtrim($url, '/'),
+ 'password' => trim((string)($settings[$tokenKey] ?? '')),
+ 'api_path' => $apiPath,
+ 'timeout' => $timeout,
+ 'verify_tls' => $verifyTls,
+ 'is_primary' => $key === 'primary',
+ ];
+ }
+ }
+
+ $primaryId = trim((string)($settings['primary_id'] ?? ''));
+ if ($primaryId !== '' && isset($instances[$primaryId])) {
+ foreach ($instances as $id => &$row) {
+ $row['is_primary'] = ($id === $primaryId);
+ }
+ unset($row);
+ } else {
+ $hasPrimary = false;
+ foreach ($instances as $row) {
+ if (!empty($row['is_primary'])) {
+ $hasPrimary = true;
+ break;
+ }
+ }
+ if (!$hasPrimary && $instances) {
+ $firstKey = array_key_first($instances);
+ if ($firstKey !== null) {
+ $instances[$firstKey]['is_primary'] = true;
+ }
+ }
+ }
+
+ return $instances;
+});
+
+$mm->registerFunction($moduleName, 'lists_primary_only', function () use ($moduleName): bool {
+ $settings = modules()->settings($moduleName);
+ return !empty($settings['lists_primary_only']);
+});
diff --git a/modules/pihole/config/module.php b/modules/pihole/config/module.php
new file mode 100644
index 00000000..51e40916
--- /dev/null
+++ b/modules/pihole/config/module.php
@@ -0,0 +1,15 @@
+ '/admin/api.php',
+ 'api_timeout_sec' => 8,
+ 'action_timeout_sec' => 120,
+ 'dashboard_refresh_sec' => 1,
+ 'lists_refresh_sec' => 5,
+ 'queries_refresh_sec' => 5,
+ 'verify_tls' => true,
+ 'lists_primary_only' => false,
+ 'instances_json' => '',
+ 'primary_id' => '',
+];
diff --git a/modules/pihole/design.json b/modules/pihole/design.json
new file mode 100644
index 00000000..0b09d222
--- /dev/null
+++ b/modules/pihole/design.json
@@ -0,0 +1,16 @@
+{
+ "eyebrow": "Modul",
+ "title": "Pi-hole",
+ "description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.",
+ "actions": [
+ { "label": "Setup", "href": "/apps/pihole/index.php?view=setup", "variant": "ghost" },
+ { "label": "Instanzen", "href": "/apps/pihole/index.php?view=instances", "variant": "secondary" }
+ ],
+ "tabs": [
+ { "label": "Dashboard", "href": "/apps/pihole/index.php?view=dashboard", "view": "dashboard" },
+ { "label": "Listen", "href": "/apps/pihole/index.php?view=lists", "view": "lists" },
+ { "label": "Queries", "href": "/apps/pihole/index.php?view=queries", "view": "queries" },
+ { "label": "Instanzen", "href": "/apps/pihole/index.php?view=instances", "view": "instances" },
+ { "label": "Setup", "href": "/apps/pihole/index.php?view=setup", "view": "setup" }
+ ]
+}
diff --git a/modules/pihole/desktop.php b/modules/pihole/desktop.php
new file mode 100644
index 00000000..653cb05c
--- /dev/null
+++ b/modules/pihole/desktop.php
@@ -0,0 +1,42 @@
+ 'pihole',
+ 'title' => (string) ($manifest['title'] ?? 'Pi-hole'),
+ 'icon' => 'PH',
+ 'entry_route' => '/apps/pihole/index.php',
+ 'window_mode' => 'window',
+ 'content_mode' => 'native-module',
+ 'default_width' => 1220,
+ 'default_height' => 860,
+ 'supports_widget' => false,
+ 'supports_tray' => false,
+ 'module_name' => 'network',
+ 'summary' => (string) ($manifest['description'] ?? 'Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.'),
+ 'native_module' => [
+ 'initializer' => 'initPiholeApp',
+ 'options' => [
+ 'entryRoute' => '/apps/pihole/index.php',
+ 'defaultView' => 'dashboard',
+ ],
+ 'assets' => [
+ 'styles' => [
+ ['href' => '/module-assets/index.php?module=pihole&path=assets/pihole.css&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole.css') ? filemtime(__DIR__ . '/assets/pihole.css') : '0'))],
+ ],
+ 'scripts' => [
+ ['src' => '/module-assets/index.php?module=pihole&path=assets/pihole.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole.js') ? filemtime(__DIR__ . '/assets/pihole.js') : '0'))],
+ ['src' => '/module-assets/index.php?module=pihole&path=assets/pihole_instances.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole_instances.js') ? filemtime(__DIR__ . '/assets/pihole_instances.js') : '0'))],
+ ['src' => '/module-assets/index.php?module=pihole&path=assets/pihole-native.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole-native.js') ? filemtime(__DIR__ . '/assets/pihole-native.js') : '0'))],
+ ],
+ ],
+ ],
+];
diff --git a/modules/pihole/docs/README.md b/modules/pihole/docs/README.md
new file mode 100644
index 00000000..4fc030bd
--- /dev/null
+++ b/modules/pihole/docs/README.md
@@ -0,0 +1,15 @@
+# Pi-hole
+
+Importiertes Nexus-Modul fuer Pi-hole Monitoring, Listenpflege und Instanzsteuerung.
+
+## Desktop-Anbindung
+
+- Desktop-App unter `/apps/pihole/index.php`
+- API-Endpunkt unter `/api/pihole/index.php`
+- native Modul-Einbettung mit globaler Desktop-Sidebar
+
+## Settings
+
+- bestehende Nexus-Settings werden ueber `nexus_module_settings` gelesen
+- neue Overrides werden in `data/module-settings/pihole.json` gespeichert
+- Instanzen werden weiterhin im Modul-Setting `instances_json` verwaltet
diff --git a/modules/pihole/module.json b/modules/pihole/module.json
new file mode 100644
index 00000000..db203175
--- /dev/null
+++ b/modules/pihole/module.json
@@ -0,0 +1,66 @@
+{
+ "title": "Pi-hole",
+ "version": "0.1.0",
+ "description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.",
+ "enabled_by_default": true,
+ "setup": {
+ "fields": [
+ {
+ "name": "api_path",
+ "label": "API Pfad",
+ "type": "text",
+ "help": "Standard ist /admin/api.php fuer v5-Instanzen. v6 wird automatisch erkannt.",
+ "required": false
+ },
+ {
+ "name": "verify_tls",
+ "label": "TLS Zertifikate pruefen",
+ "type": "checkbox",
+ "help": "Bei internen Testsystemen nur deaktivieren, wenn self-signed Zertifikate genutzt werden.",
+ "required": false
+ },
+ {
+ "name": "api_timeout_sec",
+ "label": "API Timeout Standard (Sekunden)",
+ "type": "number",
+ "help": "Timeout fuer normale Pi-hole API-Abfragen wie Dashboard und Queries. Standard: 8 Sekunden.",
+ "required": false
+ },
+ {
+ "name": "action_timeout_sec",
+ "label": "API Timeout Aktionen (Sekunden)",
+ "type": "number",
+ "help": "Timeout fuer laengere Aktionen wie Listen- oder Pi-hole-Updates. Standard: 120 Sekunden.",
+ "required": false
+ },
+ {
+ "name": "dashboard_refresh_sec",
+ "label": "Refresh Dashboard (Sekunden)",
+ "type": "number",
+ "help": "Automatische Aktualisierung fuer das Dashboard. Standard: 1 Sekunde. 0 deaktiviert den Auto-Refresh.",
+ "required": false
+ },
+ {
+ "name": "lists_refresh_sec",
+ "label": "Refresh Listen (Sekunden)",
+ "type": "number",
+ "help": "Automatische Aktualisierung fuer die Listen-Seite. Standard: 5 Sekunden. 0 deaktiviert den Auto-Refresh.",
+ "required": false
+ },
+ {
+ "name": "queries_refresh_sec",
+ "label": "Refresh Queries (Sekunden)",
+ "type": "number",
+ "help": "Automatische Aktualisierung fuer die Queries-Seite. Standard: 5 Sekunden. 0 deaktiviert den Auto-Refresh.",
+ "required": false
+ },
+ {
+ "name": "lists_primary_only",
+ "label": "Listen nur auf Primaer-Instanz bearbeiten",
+ "type": "checkbox",
+ "help": "Erzwingt Listen- und Domain-Aenderungen nur auf der Primaer-Instanz.",
+ "required": false
+ }
+ ]
+ }
+}
diff --git a/modules/pihole/pages/index.php b/modules/pihole/pages/index.php
new file mode 100644
index 00000000..a73507ff
--- /dev/null
+++ b/modules/pihole/pages/index.php
@@ -0,0 +1,260 @@
+ [
+ 'label' => 'Dashboard',
+ 'title' => 'Pi-hole Dashboard',
+ 'description' => 'Status, Blockings, Usage und direkte Steuerung fuer alle angebundenen Instanzen.',
+ 'nav_description' => 'Live-Status, Sperren und aggregierte DNS-Zahlen.',
+ ],
+ 'lists' => [
+ 'label' => 'Listen',
+ 'title' => 'Listen & Domains',
+ 'description' => 'Gravity-Updates, Top-Domains und neue Listen-/Domain-Eintraege.',
+ 'nav_description' => 'Blocklisten, Whitelist und Adlist-Pflege.',
+ ],
+ 'queries' => [
+ 'label' => 'Queries',
+ 'title' => 'Zugriffe & Blockings',
+ 'description' => 'Aktuelle Blockings und Top-Clients ueber alle Instanzen.',
+ 'nav_description' => 'Abfragen, blockierte Domains und Client-Nutzung.',
+ ],
+ 'instances' => [
+ 'label' => 'Instanzen',
+ 'title' => 'Pi-hole Instanzen',
+ 'description' => 'Hosts, Zugangsdaten und Primaer-Instanz administrieren.',
+ 'nav_description' => 'Instanzen anlegen, testen, bearbeiten und loeschen.',
+ ],
+ 'setup' => [
+ 'label' => 'Setup',
+ 'title' => 'Setup',
+ 'description' => 'Timeouts, API-Pfad und globale Pi-hole-Moduloptionen verwalten.',
+ 'nav_description' => 'Standard-Timeouts, TLS und Refresh-Regeln.',
+ ],
+];
+
+$assetVersion = static function (string $relativePath): string {
+ $fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
+ $mtime = is_file($fullPath) ? filemtime($fullPath) : false;
+
+ return $mtime === false ? '0' : (string) $mtime;
+};
+
+$renderSetup = static function (): void {
+ $moduleName = 'pihole';
+ $manifestPath = dirname(__DIR__) . '/module.json';
+ $manifestRaw = is_file($manifestPath) ? file_get_contents($manifestPath) : false;
+ $manifest = is_string($manifestRaw) && trim($manifestRaw) !== '' ? json_decode($manifestRaw, true) : [];
+ $fields = is_array($manifest['setup']['fields'] ?? null) ? $manifest['setup']['fields'] : [];
+ $settings = modules()->settings($moduleName);
+ $notice = null;
+ $error = null;
+
+ $getValue = static function (array $source, string $path): mixed {
+ $value = $source;
+ foreach (explode('.', $path) as $segment) {
+ if (!is_array($value) || !array_key_exists($segment, $value)) {
+ return null;
+ }
+ $value = $value[$segment];
+ }
+
+ return $value;
+ };
+
+ $setValue = static function (array &$target, string $path, mixed $value): void {
+ $segments = explode('.', $path);
+ $cursor = &$target;
+ foreach ($segments as $index => $segment) {
+ if ($index === count($segments) - 1) {
+ $cursor[$segment] = $value;
+ return;
+ }
+ if (!isset($cursor[$segment]) || !is_array($cursor[$segment])) {
+ $cursor[$segment] = [];
+ }
+ $cursor = &$cursor[$segment];
+ }
+ };
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'save_setup') {
+ try {
+ $nextSettings = $settings;
+ foreach ($fields as $field) {
+ if (!is_array($field)) {
+ continue;
+ }
+ $name = trim((string) ($field['name'] ?? ''));
+ $type = trim((string) ($field['type'] ?? 'text'));
+ if ($name === '') {
+ continue;
+ }
+ $raw = $_POST['settings'][$name] ?? null;
+ $value = match ($type) {
+ 'checkbox' => $raw !== null,
+ 'number' => ($raw === null || $raw === '') ? null : (is_numeric((string) $raw) ? 0 + $raw : null),
+ default => is_string($raw) ? trim($raw) : '',
+ };
+ $setValue($nextSettings, $name, $value);
+ }
+ modules()->saveSettings($moduleName, $nextSettings);
+ $settings = modules()->settings($moduleName);
+ $notice = 'Setup gespeichert.';
+ } catch (\Throwable $e) {
+ $error = $e->getMessage();
+ }
+ }
+ ?>
+
+ '/apps/pihole/index.php?view=dashboard',
+ 'lists' => '/apps/pihole/index.php?view=lists',
+ 'queries' => '/apps/pihole/index.php?view=queries',
+ 'instances' => '/apps/pihole/index.php?view=instances',
+ 'setup' => '/apps/pihole/index.php?view=setup',
+ ];
+
+ ob_start();
+ if ($view === 'setup') {
+ $renderSetup();
+ } else {
+ require dirname(__DIR__) . '/partials/' . $view . '.php';
+ }
+ $innerContent = (string) ob_get_clean();
+ ?>
+
+
+
+
+
+
+
+
Pi-hole
+
= e((string) $currentViewMeta['title']) ?>
+
= e((string) $currentViewMeta['description']) ?>
+
+
+ Desktop-App
+ Netzwerk-Tools
+ View: = e((string) $currentViewMeta['label']) ?>
+
+
+ = $innerContent ?>
+
+
+
+
+
+
+
+
+
+ Pi-hole
+
+
+
+
+ = $pageContent ?>
+
+
+
+
+
diff --git a/modules/pihole/partials/dashboard.php b/modules/pihole/partials/dashboard.php
new file mode 100644
index 00000000..ae9c2713
--- /dev/null
+++ b/modules/pihole/partials/dashboard.php
@@ -0,0 +1,134 @@
+settings('pihole');
+$instances = module_fn('pihole', 'instances');
+$hasConfig = !empty($instances);
+$refreshSeconds = (int)($settings['dashboard_refresh_sec'] ?? 1);
+if ($refreshSeconds < 0) {
+ $refreshSeconds = 1;
+}
+?>
+
+
+
+
+
+ Keine Pi-hole Instanzen vorhanden. Bitte zuerst hinzufuegen.
+
+
+
+
+
+
+
= e((string)($instance['name'] ?? $instance['id'] ?? '')) ?>
+
= e((string)($instance['url'] ?? '')) ?>
+
+
+
Primaer
+
+
+
+
+
+
+
+
+
+
+
+
+
DNS Queries (heute)
+
–
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Forward Destinations
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/pihole/partials/instances.php b/modules/pihole/partials/instances.php
new file mode 100644
index 00000000..12e9853f
--- /dev/null
+++ b/modules/pihole/partials/instances.php
@@ -0,0 +1,408 @@
+settings($moduleName);
+$notice = null;
+$error = null;
+$testResults = [];
+
+$httpRequest = static function (string $method, string $url, array $headers, ?string $body, bool $verify, int $timeout): array {
+ $raw = '';
+ $httpCode = 0;
+ $requestError = '';
+
+ if (function_exists('curl_init')) {
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => $timeout,
+ CURLOPT_CONNECTTIMEOUT => $timeout,
+ CURLOPT_SSL_VERIFYPEER => $verify,
+ CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0,
+ CURLOPT_CUSTOMREQUEST => $method,
+ CURLOPT_HTTPHEADER => $headers,
+ ]);
+ if ($body !== null) {
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+ }
+ $raw = (string)curl_exec($ch);
+ if ($raw === '' && curl_errno($ch)) {
+ $requestError = curl_error($ch);
+ }
+ $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+ } else {
+ $ctx = stream_context_create([
+ 'http' => [
+ 'method' => $method,
+ 'timeout' => $timeout,
+ 'header' => implode("\r\n", $headers),
+ 'content' => $body ?? '',
+ ],
+ 'ssl' => [
+ 'verify_peer' => $verify,
+ 'verify_peer_name' => $verify,
+ ],
+ ]);
+ $raw = (string)@file_get_contents($url, false, $ctx);
+ if ($raw === '') {
+ $requestError = 'HTTP request failed';
+ } else {
+ $httpCode = 200;
+ }
+ }
+
+ if ($requestError !== '') {
+ return ['ok' => false, 'http_code' => $httpCode, 'error' => $requestError, 'raw' => $raw, 'url' => $url];
+ }
+
+ $decoded = json_decode($raw, true);
+ if (!is_array($decoded)) {
+ return ['ok' => false, 'http_code' => $httpCode, 'error' => 'invalid_json', 'raw' => $raw, 'url' => $url];
+ }
+
+ return ['ok' => true, 'http_code' => $httpCode, 'data' => $decoded, 'url' => $url];
+};
+
+$runConnectionTest = static function (array $instance, array $settings) use ($httpRequest): array {
+ $verifyTls = !isset($settings['verify_tls']) || $settings['verify_tls'] === '1' || $settings['verify_tls'] === 1 || $settings['verify_tls'] === true;
+ $timeout = (int)($settings['api_timeout_sec'] ?? 8);
+ if ($timeout <= 0) {
+ $timeout = 8;
+ }
+
+ $url = rtrim((string)($instance['url'] ?? ''), '/');
+ $password = trim((string)($instance['password'] ?? ''));
+ $v6AuthUrl = $url . '/api/auth';
+ $v6Payload = json_encode(['password' => $password], JSON_UNESCAPED_UNICODE);
+ $v6Auth = $httpRequest('POST', $v6AuthUrl, ['Accept: application/json', 'Content-Type: application/json'], $v6Payload, $verifyTls, $timeout);
+
+ if ($v6Auth['ok']) {
+ $session = (array)(($v6Auth['data']['session'] ?? []) ?: []);
+ $sid = trim((string)($session['sid'] ?? ''));
+ if ($sid !== '') {
+ $probe = $httpRequest('GET', $url . '/api/stats/summary', ['Accept: application/json', 'X-FTL-SID: ' . $sid], null, $verifyTls, $timeout);
+ if ($probe['ok']) {
+ return [
+ 'ok' => true,
+ 'status' => 'ok',
+ 'message' => 'Verbindung OK. API v6 antwortet.',
+ 'version' => 6,
+ 'details' => ['auth' => $v6Auth, 'probe' => $probe],
+ ];
+ }
+
+ return [
+ 'ok' => false,
+ 'status' => 'error',
+ 'message' => 'v6 Auth OK, aber Stats-Endpoint antwortet nicht sauber.',
+ 'version' => 6,
+ 'details' => ['auth' => $v6Auth, 'probe' => $probe],
+ ];
+ }
+ }
+
+ $legacyUrl = $url . '/admin/api.php?summaryRaw';
+ if ($password !== '') {
+ $legacyUrl .= '&auth=' . rawurlencode($password);
+ }
+ $v5Probe = $httpRequest('GET', $legacyUrl, ['Accept: application/json'], null, $verifyTls, $timeout);
+ if ($v5Probe['ok']) {
+ return [
+ 'ok' => true,
+ 'status' => 'ok',
+ 'message' => 'Verbindung OK. API v5 antwortet.',
+ 'version' => 5,
+ 'details' => ['auth' => $v6Auth, 'probe' => $v5Probe],
+ ];
+ }
+
+ $status = 'error';
+ $message = 'Unbekannter Fehler.';
+ $httpCode = (int)($v6Auth['http_code'] ?? $v5Probe['http_code'] ?? 0);
+ $requestError = (string)($v6Auth['error'] ?? $v5Probe['error'] ?? 'error');
+ if ($httpCode === 0) {
+ $status = 'unreachable';
+ $message = 'Host nicht erreichbar oder kein HTTP-Response.';
+ } elseif ($httpCode === 401 || $httpCode === 403) {
+ $status = 'auth';
+ $message = 'Passwort oder App-Passwort falsch oder nicht berechtigt.';
+ } elseif ($requestError === 'invalid_json') {
+ $status = 'invalid';
+ $message = 'API antwortet nicht mit JSON. URL pruefen.';
+ } else {
+ $message = 'API Fehler: ' . $requestError . ' (HTTP ' . $httpCode . ')';
+ }
+
+ return [
+ 'ok' => false,
+ 'status' => $status,
+ 'message' => $message,
+ 'details' => ['auth' => $v6Auth, 'probe' => $v5Probe],
+ ];
+};
+
+$loadInstances = function (array $settings): array {
+ $normalizeSecret = static function (array $row): string {
+ $password = trim((string)($row['password'] ?? ''));
+ if ($password !== '') {
+ return $password;
+ }
+
+ return trim((string)($row['token'] ?? ''));
+ };
+
+ $instances = [];
+ $rawJson = trim((string)($settings['instances_json'] ?? ''));
+ if ($rawJson !== '') {
+ $decoded = json_decode($rawJson, true);
+ if (is_array($decoded)) {
+ foreach ($decoded as $row) {
+ if (!is_array($row)) {
+ continue;
+ }
+ $id = trim((string)($row['id'] ?? ''));
+ $url = trim((string)($row['url'] ?? ''));
+ if ($id === '' || $url === '') {
+ continue;
+ }
+ $instances[$id] = [
+ 'id' => $id,
+ 'name' => trim((string)($row['name'] ?? '')) ?: $id,
+ 'url' => $url,
+ 'password' => $normalizeSecret($row),
+ 'is_primary' => !empty($row['is_primary']),
+ ];
+ }
+ }
+ }
+
+ if (!$instances) {
+ foreach (['primary', 'secondary'] as $key) {
+ $url = trim((string)($settings[$key . '_url'] ?? ''));
+ if ($url === '') {
+ continue;
+ }
+ $instances[$key] = [
+ 'id' => $key,
+ 'name' => trim((string)($settings[$key . '_name'] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'),
+ 'url' => $url,
+ 'password' => trim((string)($settings[$key . '_token'] ?? '')),
+ 'is_primary' => $key === 'primary',
+ ];
+ }
+ }
+
+ return $instances;
+};
+
+$instances = $loadInstances($settings);
+
+$sanitizeId = function (string $id): string {
+ $id = preg_replace('/[^a-zA-Z0-9_-]/', '', $id);
+ return trim((string)$id);
+};
+
+$saveInstances = function (array $settings, array $instances): void {
+ $payload = $settings;
+ $payload['instances_json'] = json_encode(array_values($instances), JSON_UNESCAPED_UNICODE);
+ modules()->saveSettings('pihole', $payload);
+};
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $deleteId = trim((string)($_POST['delete_id'] ?? ''));
+ $testId = trim((string)($_POST['test_id'] ?? ''));
+ $currentId = trim((string)($_POST['current_id'] ?? ''));
+ $instanceId = trim((string)($_POST['instance_id'] ?? ''));
+ $name = trim((string)($_POST['name'] ?? ''));
+ $url = trim((string)($_POST['url'] ?? ''));
+ $password = trim((string)($_POST['password'] ?? ''));
+ $isPrimary = isset($_POST['is_primary']);
+
+ if ($testId !== '') {
+ if (isset($instances[$testId])) {
+ module_debug_push('pihole', [
+ 'label' => 'connection.test.start',
+ 'instance_id' => $testId,
+ 'instance_name' => (string)($instances[$testId]['name'] ?? $testId),
+ 'url' => (string)($instances[$testId]['url'] ?? ''),
+ ]);
+
+ $result = $runConnectionTest($instances[$testId], $settings);
+ $testResults[$testId] = $result;
+
+ module_debug_push('pihole', [
+ 'label' => 'connection.test.result',
+ 'instance_id' => $testId,
+ 'instance_name' => (string)($instances[$testId]['name'] ?? $testId),
+ 'result' => $result,
+ ]);
+
+ $notice = $result['message'] ?? null;
+ } else {
+ $error = 'Test-Instanz nicht gefunden.';
+ }
+ } elseif ($deleteId !== '') {
+ if (isset($instances[$deleteId])) {
+ unset($instances[$deleteId]);
+ $notice = 'Instanz geloescht.';
+ }
+ } else {
+ $instanceId = $sanitizeId($instanceId);
+ if ($instanceId === '' || $url === '') {
+ $error = 'Bitte ID und URL angeben.';
+ } elseif ($currentId === '' && isset($instances[$instanceId])) {
+ $error = 'Die Instanz-ID ist bereits vergeben. Bitte eine eindeutige ID verwenden.';
+ } elseif ($currentId !== '' && $currentId !== $instanceId && isset($instances[$instanceId])) {
+ $error = 'Die neue Instanz-ID ist bereits vergeben. Bitte eine eindeutige ID verwenden.';
+ } else {
+ $existingPassword = '';
+ if ($currentId !== '' && isset($instances[$currentId])) {
+ $existingPassword = (string)($instances[$currentId]['password'] ?? '');
+ }
+ $passwordToStore = $password !== '' ? $password : $existingPassword;
+ if ($currentId !== '' && $currentId !== $instanceId) {
+ unset($instances[$currentId]);
+ }
+ $instances[$instanceId] = [
+ 'id' => $instanceId,
+ 'name' => $name !== '' ? $name : $instanceId,
+ 'url' => $url,
+ 'password' => $passwordToStore,
+ 'is_primary' => $isPrimary,
+ ];
+
+ if ($isPrimary) {
+ foreach ($instances as $id => &$row) {
+ $row['is_primary'] = ($id === $instanceId);
+ }
+ unset($row);
+ $settings['primary_id'] = $instanceId;
+ }
+
+ $notice = $currentId !== '' ? 'Instanz aktualisiert.' : 'Instanz gespeichert.';
+ }
+ }
+
+ if (!$error) {
+ $saveInstances($settings, $instances);
+ $settings = modules()->settings($moduleName);
+ $instances = $loadInstances($settings);
+ }
+}
+
+$primaryId = trim((string)($settings['primary_id'] ?? ''));
+if ($primaryId === '') {
+ foreach ($instances as $id => $row) {
+ if (!empty($row['is_primary'])) {
+ $primaryId = $id;
+ break;
+ }
+ }
+}
+?>
+
+
+
+
+
+
+ = e($error) ?>
+
+
+
+ = e($notice) ?>
+
+
+
+
+
+
Keine Instanzen vorhanden.
+
+
+
+
+
+
+
+
+
+
= e((string)($testResults[$instance['id']]['message'] ?? '')) ?>
+
+
+
+
+
+
+
+
diff --git a/modules/pihole/partials/lists.php b/modules/pihole/partials/lists.php
new file mode 100644
index 00000000..5e7eed87
--- /dev/null
+++ b/modules/pihole/partials/lists.php
@@ -0,0 +1,88 @@
+settings('pihole');
+$instances = module_fn('pihole', 'instances');
+$hasConfig = !empty($instances);
+$refreshSeconds = (int)($settings['lists_refresh_sec'] ?? 5);
+if ($refreshSeconds < 0) {
+ $refreshSeconds = 5;
+}
+?>
+
+
+
+
Keine Instanzen konfiguriert
+
Bitte zuerst eine Pi-hole Instanz hinzufuegen.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/pihole/partials/queries.php b/modules/pihole/partials/queries.php
new file mode 100644
index 00000000..3009d56b
--- /dev/null
+++ b/modules/pihole/partials/queries.php
@@ -0,0 +1,35 @@
+settings('pihole');
+$instances = module_fn('pihole', 'instances');
+$hasConfig = !empty($instances);
+$refreshSeconds = (int)($settings['queries_refresh_sec'] ?? 5);
+if ($refreshSeconds < 0) {
+ $refreshSeconds = 5;
+}
+?>
+
+
+
+
Keine Instanzen konfiguriert
+
Bitte zuerst eine Pi-hole Instanz hinzufuegen.
+
+
+
+
+
+
diff --git a/public/api/pihole/index.php b/public/api/pihole/index.php
new file mode 100644
index 00000000..f62eecbb
--- /dev/null
+++ b/public/api/pihole/index.php
@@ -0,0 +1,5 @@
+