Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion public/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -1455,6 +1455,7 @@
if (enabledEl) enabledEl.classList.toggle('on', !!cfg.enabled);
if (chainEl) chainEl.classList.toggle('on', !!cfg.manage_dns_chain);
if (intervalEl) intervalEl.value = cfg.sync_interval_sec || 30;
var countEl = document.getElementById('pihole-top-clients-count'); if (countEl) countEl.value = cfg.top_clients_count || 1000;
phInstances = (cfg.instances || []).slice();
renderInstances();
if (window.SettingsAutosave && SettingsAutosave.resync) SettingsAutosave.resync('pihole');
Expand Down Expand Up @@ -1713,10 +1714,12 @@
var enabledEl = document.getElementById('pihole-enabled');
var chainEl = document.getElementById('pihole-manage-chain');
var intervalEl = document.getElementById('pihole-sync-interval');
var countEl = document.getElementById('pihole-top-clients-count');
var payload = {
enabled: enabledEl ? enabledEl.classList.contains('on') : false,
manage_dns_chain: chainEl ? chainEl.classList.contains('on') : false,
sync_interval_sec: intervalEl ? (parseInt(intervalEl.value, 10) || 30) : 30,
top_clients_count: countEl ? (parseInt(countEl.value, 10) || 1000) : 1000,
instances: phInstances.map(function (inst) {
var out = {
id: inst.id,
Expand All @@ -1740,15 +1743,17 @@
}

var phIntervalEl = document.getElementById('pihole-sync-interval');
var phCountEl = document.getElementById('pihole-top-clients-count');
SettingsAutosave.bind({
cluster: 'pihole',
fields: [enabledToggle, chainToggle, phIntervalEl].filter(Boolean),
fields: [enabledToggle, chainToggle, phIntervalEl, phCountEl].filter(Boolean),
statusEl: document.getElementById('pihole-status'),
valuesById: function () {
return {
'pihole-enabled': enabledToggle ? enabledToggle.classList.contains('on') : false,
'pihole-manage-chain': chainToggle ? chainToggle.classList.contains('on') : false,
'pihole-sync-interval': phIntervalEl ? phIntervalEl.value : '30',
'pihole-top-clients-count': phCountEl ? phCountEl.value : '1000',
};
},
save: function () { return savePihole(false); },
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1917,6 +1917,8 @@
"pihole.cfg.manage_chain": "DNS-Kette verwalten",
"pihole.cfg.manage_chain_hint": "GateControl setzt Pi-hole als DNS-Upstream in WireGuard. Änderungen erfordern einen WireGuard-Neustart.",
"pihole.cfg.sync_interval": "Synchronisationsintervall (Sekunden)",
"pihole.cfg.top_clients_count": "Abzurufende Top-Clients",
"pihole.cfg.top_clients_count_hint": "Wie viele Top-Clients Pi-hole pro Sync liefert — höher = vollständigere pro-Gerät/pro-Besitzer-Statistik. Default 1000.",
"pihole.cfg.instances_title": "Pi-hole-Instanzen",
"pihole.cfg.no_instances": "Keine Instanzen konfiguriert",
"pihole.cfg.add_instance": "Instanz hinzufügen",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1917,6 +1917,8 @@
"pihole.cfg.manage_chain": "Manage DNS chain",
"pihole.cfg.manage_chain_hint": "GateControl will set Pi-hole as the DNS upstream in WireGuard. Changes require a WireGuard restart.",
"pihole.cfg.sync_interval": "Sync interval (seconds)",
"pihole.cfg.top_clients_count": "Top clients to fetch",
"pihole.cfg.top_clients_count_hint": "How many top clients Pi-hole returns per sync — higher = more complete per-device/per-owner stats. Default 1000.",
"pihole.cfg.instances_title": "Pi-hole Instances",
"pihole.cfg.no_instances": "No instances configured",
"pihole.cfg.add_instance": "Add instance",
Expand Down
4 changes: 3 additions & 1 deletion src/routes/api/pihole.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ router.get('/top-domains', (req, res) => {

router.get('/top-clients', (req, res) => {
const cache = pihole.getCache();
res.json({ ok: true, data: scopeFilter(req, cache.topClients) });
// Display cap: the monitoring card is a top-10 widget; cache.topClients may now hold
// up to top_clients_count for portal.js per-device/per-owner attribution.
res.json({ ok: true, data: scopeFilter(req, cache.topClients || []).slice(0, 10) });
});

router.get('/query-types', (req, res) => {
Expand Down
1 change: 1 addition & 0 deletions src/routes/api/settings/pihole.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ router.put('/pihole', requireFeature('pihole_integration'), (req, res) => {
enabled: !!body.enabled,
sync_interval_sec: Number(body.sync_interval_sec) || 30,
manage_dns_chain: body.manage_dns_chain !== false,
top_clients_count: Math.max(1, Math.min(5000, parseInt(body.top_clients_count, 10) || 1000)),
instances,
});

Expand Down
4 changes: 4 additions & 0 deletions src/routes/api/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ router.delete('/:id', (req, res) => {
return res.status(400).json({ ok: false, error: req.t('error.users.self_delete') });
}
users.remove(id);
// Invalidate the deleted user's sessions (route-level, like the password-change flow).
if (req.sessionStore && typeof req.sessionStore.destroyByUserId === 'function') {
req.sessionStore.destroyByUserId(id);
}
res.json({ ok: true });
} catch (err) {
logger.error({ error: err.message }, 'Failed to delete user');
Expand Down
8 changes: 6 additions & 2 deletions src/services/piholeClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,12 @@ function createClient(instance) {
return (r.domains || []).map(d => ({ domain: d.domain, count: d.count }));
}

async function getTopClients(blocked = false) {
const r = await request(`/api/stats/top_clients${blocked ? '?blocked=true' : ''}`);
async function getTopClients(blocked = false, count) {
const params = [];
if (blocked) params.push('blocked=true');
if (Number.isInteger(count) && count > 0) params.push('count=' + count);
const qs = params.length ? '?' + params.join('&') : '';
const r = await request(`/api/stats/top_clients${qs}`);
return (r.clients || []).map(c => ({ ip: c.ip, count: c.count }));
}

Expand Down
5 changes: 3 additions & 2 deletions src/services/piholeConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ const DEFAULT = {
enabled: false,
sync_interval_sec: 30,
manage_dns_chain: true,
top_clients_count: 1000,
instances: [],
};

/**
* Load pihole config from settings, decrypting each instance's app_password.
* @returns {{ enabled: boolean, sync_interval_sec: number, manage_dns_chain: boolean, instances: Array }}
* @returns {{ enabled: boolean, sync_interval_sec: number, manage_dns_chain: boolean, top_clients_count: number, instances: Array }}
*/
function load() {
const raw = settings.get(KEY);
Expand All @@ -31,7 +32,7 @@ function load() {

/**
* Save pihole config to settings, encrypting each instance's app_password.
* @param {{ enabled: boolean, sync_interval_sec: number, manage_dns_chain: boolean, instances: Array }} config
* @param {{ enabled: boolean, sync_interval_sec: number, manage_dns_chain: boolean, top_clients_count: number, instances: Array }} config
*/
function save(config) {
const instances = (config.instances || []).map((inst) => ({
Expand Down
13 changes: 7 additions & 6 deletions src/services/piholeSync.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,17 @@ function createSync(deps) {
/**
* Pull all data from a single client in parallel.
*/
async function pull(client) {
async function pull(client, count) {
const [summary, history, topDomains, topClients, topClientsBlocked, queryTypes, blocking] =
await Promise.all([
client.getSummary(),
client.getHistory(),
client.getTopDomains(true),
client.getTopClients(),
client.getTopClients(false, count),
// NOTE: getTopClients(true) requires Pi-hole v6 FTL. On a v5 instance this call
// throws; the .catch(() => []) degrades silently to an empty blocked list instead
// of rejecting pull() entirely (which would mark the instance connected:false).
client.getTopClients(true).catch(() => []),
client.getTopClients(true, count).catch(() => []),
client.getQueryTypes(),
client.getBlocking(),
]);
Expand Down Expand Up @@ -148,7 +148,8 @@ function createSync(deps) {
if (!activeIds.has(id)) clientCache.delete(id);
}
const clients = config.instances.map(getOrCreateClient);
const results = await Promise.allSettled(clients.map(pull));
const count = config.top_clients_count || 1000;
const results = await Promise.allSettled(clients.map(c => pull(c, count)));

// Build per-instance metadata and collect fulfilled data
const instances = [];
Expand Down Expand Up @@ -178,9 +179,9 @@ function createSync(deps) {
const peerIps = peers.map(p => p.ip);

const summary = mergeSummary(ok.map(r => r.summary));
const topClientsRaw = mergeTopList(ok.map(r => r.topClients), 'ip', 10);
const topClientsRaw = mergeTopList(ok.map(r => r.topClients), 'ip', count);
const topClients = mapClientsToPeers(topClientsRaw, peersByIp);
const topClientsBlockedRaw = mergeTopList(ok.map(r => r.topClientsBlocked ?? []), 'ip', 10);
const topClientsBlockedRaw = mergeTopList(ok.map(r => r.topClientsBlocked ?? []), 'ip', count);
const topClientsBlocked = mapClientsToPeers(topClientsBlockedRaw, peersByIp);
const blocking = mergeBlocking(perInstanceBlocking.filter(Boolean));
const history = mergeHistory(ok.map(r => r.history), config.sync_interval_sec || 60);
Expand Down
7 changes: 6 additions & 1 deletion templates/aurora/pages/settings.njk
Original file line number Diff line number Diff line change
Expand Up @@ -890,10 +890,15 @@
</div>
<div style="font-size:11px;color:var(--text-3);padding:0 4px">{{ t('pihole.cfg.manage_chain_hint') }}</div>
</div>
<div class="form-group" style="margin-bottom:0">
<div class="form-group">
<label class="form-label" for="pihole-sync-interval">{{ t('pihole.cfg.sync_interval') }}</label>
<input type="number" id="pihole-sync-interval" value="30" min="10" max="3600" style="width:100%">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label" for="pihole-top-clients-count">{{ t('pihole.cfg.top_clients_count') }}</label>
<input type="number" id="pihole-top-clients-count" value="1000" min="1" max="5000" style="width:100%">
<div style="font-size:11px;color:var(--text-3);padding:0 4px">{{ t('pihole.cfg.top_clients_count_hint') }}</div>
</div>
<div id="pihole-status" class="autosave-status"></div>
</div>
</div>
Expand Down
7 changes: 6 additions & 1 deletion templates/default/pages/settings.njk
Original file line number Diff line number Diff line change
Expand Up @@ -1056,10 +1056,15 @@
</div>
<div style="font-size:11px;color:var(--text-3);padding:0 4px">{{ t('pihole.cfg.manage_chain_hint') }}</div>
</div>
<div class="form-group" style="margin-bottom:0">
<div class="form-group">
<label class="form-label" for="pihole-sync-interval">{{ t('pihole.cfg.sync_interval') }}</label>
<input type="number" id="pihole-sync-interval" value="30" min="10" max="3600" style="width:100%">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label" for="pihole-top-clients-count">{{ t('pihole.cfg.top_clients_count') }}</label>
<input type="number" id="pihole-top-clients-count" value="1000" min="1" max="5000" style="width:100%">
<div style="font-size:11px;color:var(--text-3);padding:0 4px">{{ t('pihole.cfg.top_clients_count_hint') }}</div>
</div>
<div id="pihole-status" class="autosave-status"></div>
</div>
</div>
Expand Down
7 changes: 6 additions & 1 deletion templates/pro/pages/settings.njk
Original file line number Diff line number Diff line change
Expand Up @@ -942,10 +942,15 @@
</div>
<div style="font-size:11px;color:var(--text-3);padding:0 4px">{{ t('pihole.cfg.manage_chain_hint') }}</div>
</div>
<div class="form-group" style="margin-bottom:0">
<div class="form-group">
<label class="form-label" for="pihole-sync-interval">{{ t('pihole.cfg.sync_interval') }}</label>
<input type="number" id="pihole-sync-interval" value="30" min="10" max="3600" style="width:100%">
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label" for="pihole-top-clients-count">{{ t('pihole.cfg.top_clients_count') }}</label>
<input type="number" id="pihole-top-clients-count" value="1000" min="1" max="5000" style="width:100%">
<div style="font-size:11px;color:var(--text-3);padding:0 4px">{{ t('pihole.cfg.top_clients_count_hint') }}</div>
</div>
<div id="pihole-status" class="autosave-status"></div>
</div>
</div>
Expand Down
38 changes: 38 additions & 0 deletions tests/pihole_topclients_count.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'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 http = require('node:http');
const { createClient } = require('../src/services/piholeClient');

let server, lastTopClientsUrl;
function start(handler){ return new Promise(r=>{ server=http.createServer(handler); server.listen(0,()=>r()); }); }
function baseUrl(){ return `http://127.0.0.1:${server.address().port}`; }
beforeEach(()=>{ lastTopClientsUrl=null; });
afterEach(()=> new Promise(r=>server.close(r)));

async function handler(req,res){
if (req.url==='/api/auth'){ res.end(JSON.stringify({ session:{ sid:'S', csrf:'c', validity:300, valid:true } })); return; }
if (req.url.startsWith('/api/stats/top_clients')){ lastTopClientsUrl=req.url; res.end(JSON.stringify({ clients:[{ip:'10.0.0.1',count:1}] })); return; }
res.statusCode=404; res.end('{}');
}

test('getTopClients(false, 500) appends count=500', async () => {
await start(handler);
const c = createClient({ id:'p1', url: baseUrl(), app_password:'pw' });
await c.getTopClients(false, 500);
assert.equal(lastTopClientsUrl, '/api/stats/top_clients?count=500');
});
test('getTopClients(true, 500) → blocked=true&count=500', async () => {
await start(handler);
const c = createClient({ id:'p1', url: baseUrl(), app_password:'pw' });
await c.getTopClients(true, 500);
assert.equal(lastTopClientsUrl, '/api/stats/top_clients?blocked=true&count=500');
});
test('getTopClients() without count is unchanged (back-compat)', async () => {
await start(handler);
const c = createClient({ id:'p1', url: baseUrl(), app_password:'pw' });
await c.getTopClients();
assert.equal(lastTopClientsUrl, '/api/stats/top_clients');
});
22 changes: 22 additions & 0 deletions tests/pihole_topclients_count_setting.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'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 piholeConfig, license;
beforeEach(async () => { await setup(); piholeConfig = require('../src/services/piholeConfig'); license = require('../src/services/license'); license._overrideForTest({ pihole_integration: true }); });
afterEach(teardown);

test('DEFAULT.top_clients_count is 1000 (load reflects it when unset)', () => {
assert.equal(piholeConfig.load().top_clients_count, 1000);
});
test('PUT persists top_clients_count (clamped)', async () => {
const agent = getAgent(); const csrf = getCsrf();
await agent.put('/api/v1/settings/pihole').set('X-CSRF-Token', csrf).send({ enabled:true, sync_interval_sec:30, top_clients_count: 250, instances: [] }).expect(200);
assert.equal(piholeConfig.load().top_clients_count, 250);
await agent.put('/api/v1/settings/pihole').set('X-CSRF-Token', csrf).send({ enabled:true, top_clients_count: -5, instances: [] }).expect(200);
assert.equal(piholeConfig.load().top_clients_count, 1);
await agent.put('/api/v1/settings/pihole').set('X-CSRF-Token', csrf).send({ enabled:true, top_clients_count: 99999, instances: [] }).expect(200);
assert.equal(piholeConfig.load().top_clients_count, 5000);
});
43 changes: 43 additions & 0 deletions tests/pihole_topclients_count_sync.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'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');

// 15 clients (> default 10). fakeClient records the count it was called with.
function makeClients(n){ return Array.from({length:n}, (_,i)=>({ ip:'10.8.0.'+(i+1), count: n-i })); }
function fakeClient(rec){
return {
id:'p1',
getSummary: async () => ({ queries:{total:100,blocked:10}, gravity:{domains_being_blocked:5}, clients:{active:15} }),
getHistory: async () => [],
getTopDomains: async () => [{domain:'a.com',count:5}],
getTopClients: async (blocked=false, count) => { rec.push({blocked, count}); return makeClients(15); },
getQueryTypes: async () => ({}),
getBlocking: async () => ({ blocking:true }),
};
}

test('syncOnce passes top_clients_count to getTopClients and caches all >10 clients', async () => {
const rec = [];
const sync = createSync({
loadConfig: () => ({ enabled:true, sync_interval_sec:30, top_clients_count:1000, manage_dns_chain:false, instances:[{id:'p1'}] }),
clientFactory: () => fakeClient(rec),
peersProvider: () => [{ id:7, name:'Dev7', ip:'10.8.0.7' }],
eventBus: { publish(){} },
dnsChain: { apply(){}, revert(){} },
});
const cache = await sync.syncOnce();
// count threaded into BOTH calls (allowed + blocked)
assert.ok(rec.some(r => r.count === 1000 && r.blocked === false), 'allowed call got count 1000');
assert.ok(rec.some(r => r.count === 1000 && r.blocked === true), 'blocked call got count 1000');
// cache holds all 15 (not capped at 10)
assert.equal(cache.topClients.length, 15);
// the BLOCKED list cap must be raised too (separate change in Step 3c — easy to miss)
assert.equal(cache.topClientsBlocked.length, 15, 'topClientsBlocked must also respect count');
// peer enrichment still works
assert.equal(cache.topClients.find(c => c.ip === '10.8.0.7').peerId, 7);
// topDomains cap unchanged (1 here, but the cap stays 10 — assert it is not raised by checking <=10)
assert.ok(cache.topDomains.length <= 10);
});
16 changes: 16 additions & 0 deletions tests/pihole_topclients_count_ui.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs'); const path = require('node:path');
test('top_clients_count field 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('pihole-top-clients-count'), `${theme}: field id`);
assert.ok(html.includes('pihole.cfg.top_clients_count'), `${theme}: i18n key`);
}
});
test('settings.js wires top_clients_count (populate + save + valuesById)', () => {
const js = fs.readFileSync(path.join(__dirname,'..','public','js','settings.js'),'utf8');
assert.ok(/pihole-top-clients-count/.test(js));
assert.ok(/top_clients_count/.test(js));
});
17 changes: 17 additions & 0 deletions tests/pihole_topclients_display_cap.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'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 } = require('./helpers/setup');
let pihole, license;
beforeEach(async () => { await setup(); pihole = require('../src/services/pihole'); license = require('../src/services/license'); license._overrideForTest({ pihole_integration: true }); });
afterEach(teardown);

test('GET /pihole/top-clients caps display at 10 even when cache holds more', async () => {
pihole.getCache = () => ({ instances:[{id:'p1',connected:true}], attribution:'per_peer', lastSyncAt:1,
topClients: Array.from({length:15}, (_,i)=>({ ip:'10.8.0.'+(i+1), count:15-i, peerId:null, peerName:null })) });
const r = await getAgent().get('/api/v1/pihole/top-clients').expect(200);
assert.ok(Array.isArray(r.body.data));
assert.ok(r.body.data.length <= 10, 'display must be capped at 10, got ' + r.body.data.length);
});
Loading
Loading