From e54f01d6631d286e6929a4f48e4ffbe9a2460aba Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:16:34 +0200 Subject: [PATCH 1/8] feat(pihole): sync per-client blocked counts into topClientsBlocked cache --- src/services/piholeSync.js | 11 ++++- tests/pihole_top_clients_blocked.test.js | 51 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 tests/pihole_top_clients_blocked.test.js 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/tests/pihole_top_clients_blocked.test.js b/tests/pihole_top_clients_blocked.test.js new file mode 100644 index 00000000..d7157e72 --- /dev/null +++ b/tests/pihole_top_clients_blocked.test.js @@ -0,0 +1,51 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { createSync } = require('../src/services/piholeSync'); + +// fakeClient whose getTopClients branches on the `blocked` argument. +function fakeClient(id, allowed, blocked) { + return { + id, + getSummary: async () => ({ queries:{total:10,blocked:3}, gravity:{domains_being_blocked:5}, clients:{active:1} }), + getHistory: async () => [], + getTopDomains: async () => [], + getTopClients: async (blockedArg = false) => (blockedArg ? blocked : allowed), + getQueryTypes: async () => ({}), + getBlocking: async () => ({ blocking: true }), + }; +} + +test('syncOnce populates topClientsBlocked, peer-enriched', async () => { + const c = fakeClient('p1', + [{ ip:'10.8.0.5', count:9 }], // allowed + [{ ip:'10.8.0.5', count:4 }]); // blocked + const sync = createSync({ + loadConfig: () => ({ enabled:true, sync_interval_sec:30, manage_dns_chain:false, instances:[{id:'p1'}] }), + clientFactory: () => c, + peersProvider: () => [{ id:5, name:'Laptop', ip:'10.8.0.5' }], + eventBus: { publish(){} }, + dnsChain: { apply(){}, revert(){} }, + }); + const cache = await sync.syncOnce(); + assert.ok(Array.isArray(cache.topClientsBlocked), 'topClientsBlocked must be an array'); + const row = cache.topClientsBlocked.find(r => r.ip === '10.8.0.5'); + assert.ok(row, 'blocked entry for the peer ip missing'); + assert.equal(row.count, 4); + assert.equal(row.peerId, 5); + assert.equal(row.peerName, 'Laptop'); + // the allowed (false-call) list must remain intact — guards against removing the existing call + const allowed = cache.topClients.find(r => r.ip === '10.8.0.5'); + assert.ok(allowed, 'topClients (allowed) must still be populated from getTopClients(false)'); + assert.equal(allowed.count, 9); +}); + +test('topClientsBlocked defaults to [] before first sync', async () => { + const sync = createSync({ + loadConfig: () => ({ enabled:false, instances:[] }), + clientFactory: () => ({}), peersProvider: () => [], eventBus:{publish(){}}, dnsChain:{apply(){},revert(){}}, + }); + assert.deepEqual(sync.getCache().topClientsBlocked, []); +}); From 5b911a82a65fe7c1b6ce1a3e3e03ee06194776d5 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:19:51 +0200 Subject: [PATCH 2/8] feat(portal): portal.widget.pihole setting (config + PUT) --- src/routes/api/settings/portal.js | 7 +++++-- src/services/portalConfig.js | 3 ++- tests/pihole_portal_setting.test.js | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 tests/pihole_portal_setting.test.js 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/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/tests/pihole_portal_setting.test.js b/tests/pihole_portal_setting.test.js new file mode 100644 index 00000000..18d79a37 --- /dev/null +++ b/tests/pihole_portal_setting.test.js @@ -0,0 +1,19 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup'); +let portalConfig, settings; +beforeEach(async () => { await setup(); portalConfig = require('../src/services/portalConfig'); settings = require('../src/services/settings'); }); +afterEach(teardown); + +test('portalConfig exposes widgets.pihole (default on)', () => { + assert.equal(portalConfig().widgets.pihole, true); +}); +test('PUT /settings/portal persists widgets.pihole=false', async () => { + const agent = getAgent(); const csrf = getCsrf(); + await agent.put('/api/v1/settings/portal').set('X-CSRF-Token', csrf).send({ widgets: { pihole: false } }).expect(200); + assert.equal(settings.get('portal.widget.pihole'), '0'); + assert.equal(portalConfig().widgets.pihole, false); +}); From 620e01d649b0b5117505f24fbbddc78ba14edd90 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:27:18 +0200 Subject: [PATCH 3/8] feat(portal): GET /pihole per-device DNS stats (gated, whitelisted, no leak) --- src/routes/api/portal.js | 33 ++++++++ tests/pihole_portal_device.test.js | 116 +++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 tests/pihole_portal_device.test.js 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/tests/pihole_portal_device.test.js b/tests/pihole_portal_device.test.js new file mode 100644 index 00000000..c6049379 --- /dev/null +++ b/tests/pihole_portal_device.test.js @@ -0,0 +1,116 @@ +// tests/pihole_portal_device.test.js +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const supertest = require('supertest'); +// setup MUST be required before config/default so that GC_DB_PATH is set to the +// temp dir BEFORE config caches its dbPath value (config is a singleton). +const { setup, teardown } = require('./helpers/setup'); +const config = require('../config/default'); + +const HOME_HOST = `home.${config.dns.domain}`; +let app, getDb, pihole, license, peerId; + +beforeEach(async () => { + await setup(); + app = require('../src/app').createApp(); + getDb = require('../src/db/connection').getDb; + pihole = require('../src/services/pihole'); + license = require('../src/services/license'); + // a peer reachable by the portal-identity header (allowed_ips /32 must match) + peerId = getDb().prepare("INSERT INTO peers (name, public_key, allowed_ips, enabled, peer_type) VALUES ('Dev','k','10.8.0.9/32',1,'regular')").run().lastInsertRowid; + license.hasFeature = () => true; // Pro feature on for the success paths +}); +afterEach(() => { teardown(); }); + +function ident(req) { + // loopback source is supertest's own connection; supply header + home Host + return req.set('X-GC-Portal-Peer-IP', '10.8.0.9').set('Host', HOME_HOST); +} + +test('identified device with data → aggregated numbers only (no raw lists)', async () => { + pihole.getCache = () => ({ + instances: [{ id:'p1', connected:true }], attribution: 'per_peer', lastSyncAt: 1750000000000, + topClients: [{ ip:'10.8.0.9', count:1019, peerId, peerName:'Dev' }], + topClientsBlocked: [{ ip:'10.8.0.9', count:228, peerId, peerName:'Dev' }], + }); + const r = await ident(supertest(app).get('/api/v1/portal/pihole')).expect(200); + assert.equal(r.body.ok, true); + assert.deepEqual(Object.keys(r.body.data).sort(), ['allowed','asOf','blocked','blockedPct','total']); + assert.equal(r.body.data.total, 1247); + assert.equal(r.body.data.blocked, 228); + assert.equal(r.body.data.allowed, 1019); + assert.equal(r.body.data.blockedPct, 18); +}); + +test('device in NEITHER list → reason no_data (not faked zeros)', async () => { + pihole.getCache = () => ({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1, topClients:[], topClientsBlocked:[] }); + const r = await ident(supertest(app).get('/api/v1/portal/pihole')).expect(200); + assert.equal(r.body.data, null); + assert.equal(r.body.reason, 'no_data'); +}); + +test('device IN a list with genuine count:0 → data total:0 (NOT no_data)', async () => { + pihole.getCache = () => ({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1, + topClients: [{ ip:'10.8.0.9', count:0, peerId, peerName:'Dev' }], topClientsBlocked: [] }); + const r = await ident(supertest(app).get('/api/v1/portal/pihole')).expect(200); + assert.notEqual(r.body.data, null, 'in-list device must not be no_data'); + assert.equal(r.body.data.total, 0); + assert.equal(r.body.data.blocked, 0); + assert.equal(r.body.data.blockedPct, 0); +}); + +test('device in topClientsBlocked ONLY → allowed:0, blocked:N, pct:100', async () => { + pihole.getCache = () => ({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1, + topClients: [], topClientsBlocked: [{ ip:'10.8.0.9', count:7, peerId, peerName:'Dev' }] }); + const r = await ident(supertest(app).get('/api/v1/portal/pihole')).expect(200); + assert.equal(r.body.data.allowed, 0); + assert.equal(r.body.data.blocked, 7); + assert.equal(r.body.data.total, 7); + assert.equal(r.body.data.blockedPct, 100); +}); + +test('device in topClients ONLY → blocked:0', async () => { + pihole.getCache = () => ({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1, + topClients: [{ ip:'10.8.0.9', count:5, peerId, peerName:'Dev' }], topClientsBlocked: [] }); + const r = await ident(supertest(app).get('/api/v1/portal/pihole')).expect(200); + assert.equal(r.body.data.blocked, 0); + assert.equal(r.body.data.total, 5); + assert.equal(r.body.data.blockedPct, 0); +}); + +test('collapsed attribution → reason collapsed', async () => { + pihole.getCache = () => ({ instances:[{id:'p1',connected:true}], attribution:'collapsed', lastSyncAt:1, topClients:[], topClientsBlocked:[] }); + const r = await ident(supertest(app).get('/api/v1/portal/pihole')).expect(200); + assert.equal(r.body.data, null); + assert.equal(r.body.reason, 'collapsed'); +}); + +test('feature off → reason unavailable', async () => { + license.hasFeature = () => false; + const r = await ident(supertest(app).get('/api/v1/portal/pihole')).expect(200); + assert.equal(r.body.data, null); + assert.equal(r.body.reason, 'unavailable'); +}); + +test('not configured (no instances) → reason unavailable', async () => { + pihole.getCache = () => ({ instances: [], attribution:'collapsed', lastSyncAt:null, topClients:[], topClientsBlocked:[] }); + const r = await ident(supertest(app).get('/api/v1/portal/pihole')).expect(200); + assert.equal(r.body.data, null); + assert.equal(r.body.reason, 'unavailable'); +}); + +test('unidentified (no header) → reason unidentified', async () => { + pihole.getCache = () => ({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1, topClients:[], topClientsBlocked:[] }); + const r = await supertest(app).get('/api/v1/portal/pihole').set('Host', HOME_HOST).expect(200); + assert.equal(r.body.data, null); + assert.equal(r.body.reason, 'unidentified'); +}); + +test('widget toggled off → 404', async () => { + require('../src/services/settings').set('portal.widget.pihole', '0'); + const r = await ident(supertest(app).get('/api/v1/portal/pihole')); + assert.equal(r.status, 404); +}); From e6c0146ffda01e5fe8be8851b86970d833255ce3 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:38:51 +0200 Subject: [PATCH 4/8] =?UTF-8?q?test(portal):=20regression=20=E2=80=94=20pi?= =?UTF-8?q?hole=20widget=20never=20leaks=20other=20devices=20or=20raw=20fi?= =?UTF-8?q?elds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/pihole_portal_no_leak.test.js | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/pihole_portal_no_leak.test.js diff --git a/tests/pihole_portal_no_leak.test.js b/tests/pihole_portal_no_leak.test.js new file mode 100644 index 00000000..bce756ee --- /dev/null +++ b/tests/pihole_portal_no_leak.test.js @@ -0,0 +1,58 @@ +// tests/pihole_portal_no_leak.test.js +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); const path = require('node:path'); +const supertest = require('supertest'); +const { setup, teardown } = require('./helpers/setup'); +const config = require('../config/default'); +const HOME_HOST = `home.${config.dns.domain}`; + +let app, getDb, pihole, license, peerId, otherId; +beforeEach(async () => { + await setup(); + app = require('../src/app').createApp(); + getDb = require('../src/db/connection').getDb; + pihole = require('../src/services/pihole'); + license = require('../src/services/license'); + peerId = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type) VALUES ('Mine','k1','10.8.0.9/32',1,'regular')").run().lastInsertRowid; + otherId = getDb().prepare("INSERT INTO peers (name,public_key,allowed_ips,enabled,peer_type) VALUES ('Other','k2','10.8.0.50/32',1,'regular')").run().lastInsertRowid; + license.hasFeature = () => true; +}); +afterEach(() => { teardown(); }); + +test('behavioral: response exposes only the aggregate, never other devices or raw fields', async () => { + pihole.getCache = () => ({ + instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt: 1750000000000, + topClients: [{ ip:'10.8.0.9', count:100, peerId, peerName:'Mine' }, { ip:'10.8.0.50', count:999, peerId: otherId, peerName:'Other' }], + topClientsBlocked: [{ ip:'10.8.0.9', count:10, peerId, peerName:'Mine' }, { ip:'10.8.0.50', count:500, peerId: otherId, peerName:'Other' }], + }); + const r = await supertest(app).get('/api/v1/portal/pihole').set('X-GC-Portal-Peer-IP','10.8.0.9').set('Host', HOME_HOST).expect(200); + const raw = JSON.stringify(r.body); + // only this device's numbers + assert.equal(r.body.data.allowed, 100); + assert.equal(r.body.data.blocked, 10); + // no other device, no ip, no peerId, no owner mapping + assert.ok(!raw.includes('10.8.0.50'), 'other device IP leaked'); + assert.ok(!raw.includes('Other'), 'other device name leaked'); + assert.ok(!/\bip\b|peerId|peerName|user_id|owner_name|topClients/.test(raw), 'raw field leaked: ' + raw); + assert.equal(r.body.data.total, 110); // 999/500 of the other device must NOT appear +}); + +test('structural: the /pihole handler (server) serializes only a locally built aggregate', () => { + const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'routes', 'api', 'portal.js'), 'utf8'); + // isolate the /pihole handler body — capture group 1 starts at const pid (the + // device-scoped logic), so topClients references appear BEFORE any res.json() in + // that scope; \s* before the closing brace tolerates 2-space indentation + const m = src.match(/router\.get\(\s*['"]\/pihole['"][\s\S]*?(const pid\s*=[\s\S]*?)\n\s*\}\);/); + assert.ok(m, '/pihole handler not found'); + const body = m[1]; // capture group 1: device-scoped logic only (after early-exit gates) + // [\s\S]*? (not [^)]/[^}]) so multi-line res.json(...) calls can't slip past + assert.ok(!/res\.json\(\s*cache\s*\)/.test(body), 'handler returns raw cache'); + assert.ok(!/res\.json\(\s*\{[\s\S]*?\.\.\.\s*cache/.test(body), 'handler spreads cache into response'); + assert.ok(!/res\.json\(\s*[\s\S]*?topClients/.test(body), 'handler serializes topClients'); + // the success res.json must reference the whitelisted keys + assert.ok(/total[\s\S]*blocked[\s\S]*allowed[\s\S]*blockedPct[\s\S]*asOf/.test(body), 'whitelisted aggregate keys missing'); +}); From 1770f47e0c9e16c4cedda57544bcca38d245abbe Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:45:07 +0200 Subject: [PATCH 5/8] feat(portal): admin toggle for pihole widget (3 themes) + i18n --- public/js/settings.js | 8 ++++++-- src/i18n/de.json | 1 + src/i18n/en.json | 1 + templates/aurora/pages/settings.njk | 4 ++++ templates/default/pages/settings.njk | 4 ++++ templates/pro/pages/settings.njk | 4 ++++ tests/pihole_portal_settings_ui.test.js | 17 +++++++++++++++++ 7 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/pihole_portal_settings_ui.test.js 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..6327f134 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", diff --git a/src/i18n/en.json b/src/i18n/en.json index 484bab77..c48d0b10 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", 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/pro/pages/settings.njk b/templates/pro/pages/settings.njk index b1d555ef..7265ec44 100644 --- a/templates/pro/pages/settings.njk +++ b/templates/pro/pages/settings.njk @@ -1025,6 +1025,10 @@ {{ t('settings.portal.widget_services') }}
+
+ {{ t('settings.portal.widget_pihole') }} +
+
diff --git a/tests/pihole_portal_settings_ui.test.js b/tests/pihole_portal_settings_ui.test.js new file mode 100644 index 00000000..0d4a5fd4 --- /dev/null +++ b/tests/pihole_portal_settings_ui.test.js @@ -0,0 +1,17 @@ +// tests/pihole_portal_settings_ui.test.js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); const path = require('node:path'); +test('pihole widget toggle present in all 3 theme settings pages', () => { + for (const theme of ['aurora','default','pro']) { + const html = fs.readFileSync(path.join(__dirname,'..','templates',theme,'pages','settings.njk'),'utf8'); + assert.ok(html.includes('portal-widget-pihole'), `${theme}: toggle id missing`); + assert.ok(html.includes('settings.portal.widget_pihole'), `${theme}: i18n key missing`); + } +}); +test('settings.js wires pihole toggle into the portal cluster + PUT', () => { + const js = fs.readFileSync(path.join(__dirname,'..','public','js','settings.js'),'utf8'); + assert.ok(/portal-widget-pihole/.test(js)); + assert.ok(/pihole:\s*widgetPihole/.test(js)); +}); From 28dd6487af16970aab68c060c3813e0a0e75baeb Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:49:48 +0200 Subject: [PATCH 6/8] feat(portal): DNS protection widget (per-device pihole stats) + i18n --- public/css/portal.css | 18 ++++++++++++++ public/js/portal.js | 34 +++++++++++++++++++++++++++ src/i18n/de.json | 10 ++++++++ src/i18n/en.json | 10 ++++++++ templates/portal/portal.njk | 25 +++++++++++++++++++- tests/pihole_portal_widget_ui.test.js | 28 ++++++++++++++++++++++ 6 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/pihole_portal_widget_ui.test.js 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..fe91382d 100644 --- a/public/js/portal.js +++ b/public/js/portal.js @@ -346,9 +346,43 @@ }); } + // ─── 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) { + 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/src/i18n/de.json b/src/i18n/de.json index 6327f134..dc93a422 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -1994,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 c48d0b10..9a1a221b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1994,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/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 %} +