diff --git a/public/css/portal.css b/public/css/portal.css index 3c4239f1..aac69107 100644 --- a/public/css/portal.css +++ b/public/css/portal.css @@ -272,6 +272,24 @@ body::before{ /* Reserve height for services card while tiles load */ .c-services.loading{min-height:200px} +/* ============================================================ + PI-HOLE WIDGET + ============================================================ */ +.c-pihole .pihole-body{display:flex;flex-direction:column;gap:10px} +.pihole-rate{display:flex;align-items:baseline;gap:3px} +.pihole-rate b{font-size:2.2rem;font-weight:700;color:var(--teal, #3ec8b0);line-height:1} +.pihole-rate span{font-size:1.1rem;color:var(--muted)} +.pihole-bar{height:6px;border-radius:3px;background:var(--border);overflow:hidden} +.pihole-bar i{display:block;height:100%;border-radius:3px;background:var(--teal, #3ec8b0); + transition:width .4s ease;width:0} +.pihole-nums{display:flex;gap:12px;flex-wrap:wrap} +.pihole-nums>div{display:flex;flex-direction:column;gap:2px;min-width:60px} +.pihole-nums b{font-size:1.1rem;font-weight:600;color:var(--text)} +.pihole-nums span{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em} +.pihole-foot{font-size:11px;color:var(--faint);margin-top:4px} +.pihole-msg{font-size:13px;color:var(--muted);padding:10px 0;line-height:1.5} +.c-pihole.loading{min-height:160px} + /* ============================================================ REDUCED MOTION ============================================================ */ diff --git a/public/js/portal.js b/public/js/portal.js index e50136d2..283e4803 100644 --- a/public/js/portal.js +++ b/public/js/portal.js @@ -346,9 +346,44 @@ }); } + // ─── Pi-hole widget ───────────────────────────────────────────────────────── + function hydratePihole() { + const card = document.querySelector('.pihole-widget'); + if (!card) return; // widget toggled off → not in DOM → no fetch + setLoading(card, true); + fetch('/api/v1/portal/pihole') + .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) + .then(function (body) { + setLoading(card, false); + const msg = document.getElementById('piMsg'); + const bodyEl = card.querySelector('.pihole-body'); + if (!body.ok || body.data === null) { + if (body.reason === 'unavailable') { card.style.display = 'none'; return; } + var key = { unavailable:'piholeUnavailable', collapsed:'piholeCollapsed', no_data:'piholeNoData', unidentified:'piholeUnidentified' }[body.reason] || 'piholeUnavailable'; + if (bodyEl) bodyEl.style.display = 'none'; + if (msg) { msg.textContent = (PT[key] || ''); msg.style.display = 'block'; } // PT = i18n map (portal.js line 14) + return; + } + if (bodyEl) bodyEl.style.display = ''; + var d = body.data; + document.getElementById('piPct').textContent = String(d.blockedPct); + var bar = document.getElementById('piBar'); if (bar) bar.style.width = d.blockedPct + '%'; + document.getElementById('piTotal').textContent = String(d.total); + document.getElementById('piBlocked').textContent = String(d.blocked); + document.getElementById('piAllowed').textContent = String(d.allowed); + // genuine idle device (in a list, count 0) → show "no queries today" note (spec §5) + if (msg) { + if (d.total === 0) { msg.textContent = (PT['piholeZeroQueries'] || ''); msg.style.display = 'block'; } + else { msg.style.display = 'none'; } + } + }) + .catch(function () { setLoading(card, false); showError(card, hydratePihole); }); + } + // ─── Boot ─────────────────────────────────────────────────────────────────── hydrateDevice(); hydrateTraffic(); hydrateServices(); + hydratePihole(); })(); diff --git a/public/js/settings.js b/public/js/settings.js index 681f1e7f..1ed64275 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1763,9 +1763,10 @@ var widgetDevice = document.getElementById('portal-widget-device'); var widgetTraffic = document.getElementById('portal-widget-traffic'); var widgetServices = document.getElementById('portal-widget-services'); + var widgetPihole = document.getElementById('portal-widget-pihole'); if (!enabledToggle) return; - [enabledToggle, widgetDevice, widgetTraffic, widgetServices].forEach(function (el) { + [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole].forEach(function (el) { if (el) el.addEventListener('click', function () { el.classList.toggle('on'); el.dispatchEvent(new Event('change')); @@ -1784,12 +1785,13 @@ setToggle(widgetDevice, d.widgets && d.widgets.device); setToggle(widgetTraffic, d.widgets && d.widgets.traffic); setToggle(widgetServices, d.widgets && d.widgets.services); + setToggle(widgetPihole, d.widgets && d.widgets.pihole); if (window.SettingsAutosave && SettingsAutosave.resync) SettingsAutosave.resync('portal'); }).catch(function (err) { console.error('Failed to load portal settings:', err); }); - var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices].filter(Boolean); + var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices, widgetPihole].filter(Boolean); SettingsAutosave.bind({ cluster: 'portal', fields: portalFields, @@ -1800,6 +1802,7 @@ 'portal-widget-device': widgetDevice ? widgetDevice.classList.contains('on') : true, 'portal-widget-traffic': widgetTraffic ? widgetTraffic.classList.contains('on') : true, 'portal-widget-services': widgetServices ? widgetServices.classList.contains('on') : true, + 'portal-widget-pihole': widgetPihole ? widgetPihole.classList.contains('on') : true, }; }, save: function () { @@ -1809,6 +1812,7 @@ device: widgetDevice ? widgetDevice.classList.contains('on') : true, traffic: widgetTraffic ? widgetTraffic.classList.contains('on') : true, services: widgetServices ? widgetServices.classList.contains('on') : true, + pihole: widgetPihole ? widgetPihole.classList.contains('on') : true, }, }); }, diff --git a/src/i18n/de.json b/src/i18n/de.json index d7139287..dc93a422 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -1979,6 +1979,7 @@ "settings.portal.widget_device": "Gerätestatus", "settings.portal.widget_traffic": "Traffic-Diagramm", "settings.portal.widget_services": "Dienste", + "settings.portal.widget_pihole": "Pi-hole-Widget", "settings.portal.saved": "Portal-Einstellungen gespeichert", "settings.portal.host_not_verified": "Domain ist nicht verifiziert", "settings.portal.host_invalid_prefix": "Ungültiges Subdomain-Präfix", @@ -1993,6 +1994,16 @@ "settings.portal.host_note": "Das Portal bleibt intern erreichbar (nur VPN). Eine verifizierte öffentliche Domain liefert ein gültiges Zertifikat statt der Browser-Warnung — die Subdomain und ihr Zertifikat werden dadurch aber in öffentlichen Certificate-Transparency-Logs auffindbar, und der TLS-Handshake ist öffentlich erreichbar.", "settings.portal.switch_warning": "Beim Wechsel ist das Portal kurz nicht erreichbar, bis das Zertifikat ausgestellt ist; der bisherige Name entfällt. Verschwindet die Warnung nicht binnen weniger Minuten, wechsle zurück auf „Intern (Standard)“.", "settings.portal.no_domains_hint": "Noch keine verifizierten Domains. Lege unter Einstellungen → Allgemein → Domains eine an und verifiziere sie, um eine öffentliche Portal-Adresse zu nutzen.", + "portal.pihole.title": "DNS-Schutz", + "portal.pihole.total": "Anfragen", + "portal.pihole.blocked": "Geblockt", + "portal.pihole.allowed": "Durchgelassen", + "portal.pihole.foot": "Pi-hole · heute", + "portal.pihole.unavailable": "Pro-Gerät-Statistik nicht verfügbar — Pi-hole unterscheidet die Geräte nicht", + "portal.pihole.collapsed": "Pro-Gerät-Statistik nicht verfügbar — Pi-hole unterscheidet die Geräte nicht", + "portal.pihole.no_data": "Noch keine Daten für dieses Gerät verfügbar", + "portal.pihole.unidentified": "Gerät nicht erkannt", + "portal.pihole.zero_queries": "Keine Anfragen heute", "settings.autosave.saved": "Gespeichert", "settings.autosave.error": "Speichern fehlgeschlagen", "settings.autosave.pending": "Wird gespeichert, sobald alle Pflichtfelder ausgefüllt sind", diff --git a/src/i18n/en.json b/src/i18n/en.json index 484bab77..9a1a221b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1979,6 +1979,7 @@ "settings.portal.widget_device": "Device status", "settings.portal.widget_traffic": "Traffic chart", "settings.portal.widget_services": "Services", + "settings.portal.widget_pihole": "Pi-hole widget", "settings.portal.saved": "Portal settings saved", "settings.portal.host_not_verified": "Domain is not verified", "settings.portal.host_invalid_prefix": "Invalid subdomain prefix", @@ -1993,6 +1994,16 @@ "settings.portal.host_note": "The portal stays internal-only (VPN). A verified public domain serves a valid certificate instead of the browser warning — but the subdomain and its certificate become discoverable in public Certificate Transparency logs and the TLS handshake is publicly reachable.", "settings.portal.switch_warning": "While switching, the portal is briefly unreachable until the certificate is issued, and the previous name stops working. If the warning does not clear within a few minutes, switch back to Internal (default).", "settings.portal.no_domains_hint": "No verified domains yet. Add and verify one under Settings → General → Domains to use a public portal address.", + "portal.pihole.title": "DNS protection", + "portal.pihole.total": "Queries", + "portal.pihole.blocked": "Blocked", + "portal.pihole.allowed": "Allowed", + "portal.pihole.foot": "Pi-hole · today", + "portal.pihole.unavailable": "Per-device stats unavailable — Pi-hole can't tell the devices apart", + "portal.pihole.collapsed": "Per-device stats unavailable — Pi-hole can't tell the devices apart", + "portal.pihole.no_data": "No data for this device yet", + "portal.pihole.unidentified": "Device not recognised", + "portal.pihole.zero_queries": "No queries today", "settings.autosave.saved": "Saved", "settings.autosave.error": "Save failed", "settings.autosave.pending": "Will save once all required fields are filled", diff --git a/src/routes/api/portal.js b/src/routes/api/portal.js index 9aae5d53..42e2d5b9 100644 --- a/src/routes/api/portal.js +++ b/src/routes/api/portal.js @@ -7,6 +7,8 @@ const caddyAcl = require('../../services/caddyAcl'); const { getDb } = require('../../db/connection'); const logger = require('../../utils/logger'); const portalConfig = require('../../services/portalConfig'); +const pihole = require('../../services/pihole'); +const license = require('../../services/license'); const router = Router(); @@ -130,4 +132,35 @@ router.get('/services', (req, res) => { } }); +router.get('/pihole', (req, res) => { + try { + if (!portalConfig().widgets.pihole) return res.status(404).json({ ok: false }); + // Reuse the existing Pro feature gate; INLINE (not requireFeature middleware) so the + // frontend gets a clean "hide" signal (data:null) instead of a 403. + const cache = pihole.getCache(); + if (!license.hasFeature('pihole_integration') || !cache.instances || cache.instances.length === 0) { + return res.json({ ok: true, data: null, reason: 'unavailable' }); + } + if (req.portalPeerId == null) return unidentified(res); // reuse the existing helper (siblings do too) + if (cache.attribution === 'collapsed') return res.json({ ok: true, data: null, reason: 'collapsed' }); + + const pid = req.portalPeerId; + const allowedEntry = (cache.topClients || []).find(c => c.peerId === pid); + const blockedEntry = (cache.topClientsBlocked || []).find(c => c.peerId === pid); + // Device present in NEITHER top-N list → no_data. Do NOT fake zeros (would lie that + // Pi-hole saw this device). Keep this null-check — do not collapse back to flat 0. + if (!allowedEntry && !blockedEntry) return res.json({ ok: true, data: null, reason: 'no_data' }); + + const allowed = allowedEntry ? allowedEntry.count : 0; + const blocked = blockedEntry ? blockedEntry.count : 0; + const total = allowed + blocked; + const blockedPct = total ? Math.round((blocked / total) * 100) : 0; + res.json({ ok: true, data: { total, blocked, allowed, blockedPct, asOf: cache.lastSyncAt } }); + } catch (err) { + logger.error({ error: err.message }, 'portal /pihole failed'); + // intentional: a service/cache error is 'unavailable', NOT 'unidentified' + return res.json({ ok: true, data: null, reason: 'unavailable' }); + } +}); + module.exports = router; diff --git a/src/routes/api/settings/portal.js b/src/routes/api/settings/portal.js index bf1c8a54..aaf20752 100644 --- a/src/routes/api/settings/portal.js +++ b/src/routes/api/settings/portal.js @@ -1,7 +1,7 @@ 'use strict'; // Portal settings cluster — master switch + per-widget toggles + public host. -// Keys: portal.enabled, portal.widget.{device,traffic,services}, +// Keys: portal.enabled, portal.widget.{device,traffic,services,pihole}, // portal.base_domain, portal.prefix const { Router } = require('express'); @@ -33,7 +33,7 @@ router.get('/portal', (req, res) => { * PUT /api/v1/settings/portal — Update portal master switch + widget toggles + host * * Accepts: - * { enabled: bool, widgets: { device: bool, traffic: bool, services: bool }, + * { enabled: bool, widgets: { device: bool, traffic: bool, services: bool, pihole: bool }, * base_domain: string, prefix: string } */ router.put('/portal', (req, res) => { @@ -53,6 +53,9 @@ router.put('/portal', (req, res) => { if (widgets.services !== undefined) { settings.set('portal.widget.services', widgets.services ? '1' : '0'); } + if (widgets.pihole !== undefined) { + settings.set('portal.widget.pihole', widgets.pihole ? '1' : '0'); + } // Host change (base_domain + prefix committed together). if (body.base_domain !== undefined || body.prefix !== undefined) { diff --git a/src/services/piholeSync.js b/src/services/piholeSync.js index 6addc739..894545c1 100644 --- a/src/services/piholeSync.js +++ b/src/services/piholeSync.js @@ -60,6 +60,7 @@ function createSync(deps) { history: [], topDomains: [], topClients: [], + topClientsBlocked: [], queryTypes: {}, blocking: { state: 'unknown', timer: null }, instances: [], @@ -71,16 +72,19 @@ function createSync(deps) { * Pull all data from a single client in parallel. */ async function pull(client) { - const [summary, history, topDomains, topClients, queryTypes, blocking] = + const [summary, history, topDomains, topClients, topClientsBlocked, queryTypes, blocking] = await Promise.all([ client.getSummary(), client.getHistory(), client.getTopDomains(true), client.getTopClients(), + // NOTE: getTopClients(true) requires Pi-hole v6 FTL. A v5 instance throws here, + // which rejects pull() entirely → Promise.allSettled marks it connected:false. + client.getTopClients(true), client.getQueryTypes(), client.getBlocking(), ]); - return { summary, history, topDomains, topClients, queryTypes, blocking }; + return { summary, history, topDomains, topClients, topClientsBlocked, queryTypes, blocking }; } /** @@ -175,6 +179,8 @@ function createSync(deps) { const summary = mergeSummary(ok.map(r => r.summary)); const topClientsRaw = mergeTopList(ok.map(r => r.topClients), 'ip', 10); const topClients = mapClientsToPeers(topClientsRaw, peersByIp); + const topClientsBlockedRaw = mergeTopList(ok.map(r => r.topClientsBlocked ?? []), 'ip', 10); + const topClientsBlocked = mapClientsToPeers(topClientsBlockedRaw, peersByIp); const blocking = mergeBlocking(perInstanceBlocking.filter(Boolean)); const history = mergeHistory(ok.map(r => r.history), config.sync_interval_sec || 60); const topDomains = mergeTopList(ok.map(r => r.topDomains), 'domain', 10); @@ -188,6 +194,7 @@ function createSync(deps) { history, topDomains, topClients, + topClientsBlocked, queryTypes, blocking, instances, diff --git a/src/services/portalConfig.js b/src/services/portalConfig.js index 67e0fb24..53511fcb 100644 --- a/src/services/portalConfig.js +++ b/src/services/portalConfig.js @@ -6,7 +6,7 @@ const settings = require('./settings'); * Returns the current VPN landing portal configuration derived from settings. * All values default to enabled ('1') unless explicitly set to '0'. * - * @returns {{ enabled: boolean, widgets: { device: boolean, traffic: boolean, services: boolean } }} + * @returns {{ enabled: boolean, widgets: { device: boolean, traffic: boolean, services: boolean, pihole: boolean } }} */ const on = (key) => settings.get(key, '1') !== '0'; @@ -17,6 +17,7 @@ function portalConfig() { device: on('portal.widget.device'), traffic: on('portal.widget.traffic'), services: on('portal.widget.services'), + pihole: on('portal.widget.pihole'), }, }; } diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk index fe551f06..9e51a489 100644 --- a/templates/aurora/pages/settings.njk +++ b/templates/aurora/pages/settings.njk @@ -973,6 +973,10 @@ {{ t('settings.portal.widget_services') }}
+
+ {{ t('settings.portal.widget_pihole') }} +
+
diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk index 7396f095..afdec830 100644 --- a/templates/default/pages/settings.njk +++ b/templates/default/pages/settings.njk @@ -1138,6 +1138,10 @@ {{ t('settings.portal.widget_services') }}
+
+ {{ t('settings.portal.widget_pihole') }} +
+
diff --git a/templates/portal/portal.njk b/templates/portal/portal.njk index 9bc93dff..64850490 100644 --- a/templates/portal/portal.njk +++ b/templates/portal/portal.njk @@ -24,7 +24,12 @@ unavailable: t('portal.widget.unavailable'), retry: t('portal.retry'), online: t('portal.device.online'), - offline: t('portal.device.offline') + offline: t('portal.device.offline'), + piholeUnavailable: t('portal.pihole.unavailable'), + piholeCollapsed: t('portal.pihole.collapsed'), + piholeNoData: t('portal.pihole.no_data'), + piholeUnidentified: t('portal.pihole.unidentified'), + piholeZeroQueries: t('portal.pihole.zero_queries') } | dump | safe }} @@ -109,6 +114,24 @@ {% endif %} + {% if widgets.pihole %} + +
+

{{ t('portal.pihole.title') }}

+
+
%
+
+
+
{{ t('portal.pihole.total') }}
+
{{ t('portal.pihole.blocked') }}
+
{{ t('portal.pihole.allowed') }}
+
+
{{ t('portal.pihole.foot') }}
+
+ +
+ {% endif %} +