Skip to content
Merged
18 changes: 18 additions & 0 deletions public/css/portal.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
============================================================ */
Expand Down
35 changes: 35 additions & 0 deletions public/js/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

})();
8 changes: 6 additions & 2 deletions public/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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,
Expand All @@ -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 () {
Expand All @@ -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,
},
});
},
Expand Down
11 changes: 11 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions src/routes/api/portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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;
7 changes: 5 additions & 2 deletions src/routes/api/settings/portal.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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) => {
Expand All @@ -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) {
Expand Down
11 changes: 9 additions & 2 deletions src/services/piholeSync.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function createSync(deps) {
history: [],
topDomains: [],
topClients: [],
topClientsBlocked: [],
queryTypes: {},
blocking: { state: 'unknown', timer: null },
instances: [],
Expand All @@ -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 };
}

/**
Expand Down Expand Up @@ -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);
Expand All @@ -188,6 +194,7 @@ function createSync(deps) {
history,
topDomains,
topClients,
topClientsBlocked,
queryTypes,
blocking,
instances,
Expand Down
3 changes: 2 additions & 1 deletion src/services/portalConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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'),
},
};
}
Expand Down
4 changes: 4 additions & 0 deletions templates/aurora/pages/settings.njk
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,10 @@
<span class="l">{{ t('settings.portal.widget_services') }}</span>
<div class="toggle" id="portal-widget-services" data-managed="true"></div>
</div>
<div class="set-row">
<span class="l">{{ t('settings.portal.widget_pihole') }}</span>
<div class="toggle" id="portal-widget-pihole" data-managed="true"></div>
</div>
<div id="portal-message" style="display:none"></div>
<div id="portal-status" class="autosave-status"></div>
</div>
Expand Down
4 changes: 4 additions & 0 deletions templates/default/pages/settings.njk
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,10 @@
<span style="font-size:13px">{{ t('settings.portal.widget_services') }}</span>
<div class="toggle" id="portal-widget-services" data-managed="true"></div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--bg-base);border:1px solid var(--border);border-radius:var(--radius-sm)">
<span style="font-size:13px">{{ t('settings.portal.widget_pihole') }}</span>
<div class="toggle" id="portal-widget-pihole" data-managed="true"></div>
</div>
<div id="portal-message" style="display:none"></div>
<div id="portal-status" class="autosave-status"></div>
</div>
Expand Down
25 changes: 24 additions & 1 deletion templates/portal/portal.njk
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}</script>
<script src="/js/portal.js" defer></script>
</head>
Expand Down Expand Up @@ -109,6 +114,24 @@
</section>
{% endif %}

{% if widgets.pihole %}
<!-- pi-hole / DNS protection -->
<section class="card c-pihole pihole-widget">
<h2><span class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6l7-3z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg></span>{{ t('portal.pihole.title') }}</h2>
<div class="pihole-body">
<div class="pihole-rate"><b id="piPct">–</b><span>%</span></div>
<div class="pihole-bar"><i id="piBar"></i></div>
<div class="pihole-nums">
<div><b id="piTotal">–</b><span>{{ t('portal.pihole.total') }}</span></div>
<div><b id="piBlocked">–</b><span>{{ t('portal.pihole.blocked') }}</span></div>
<div><b id="piAllowed">–</b><span>{{ t('portal.pihole.allowed') }}</span></div>
</div>
<div class="pihole-foot">{{ t('portal.pihole.foot') }}</div>
</div>
<div class="pihole-msg" id="piMsg" style="display:none"></div>
</section>
{% endif %}

</main>

<footer class="foot">
Expand Down
4 changes: 4 additions & 0 deletions templates/pro/pages/settings.njk
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,10 @@
<span style="font-size:13px">{{ t('settings.portal.widget_services') }}</span>
<div class="toggle" id="portal-widget-services" data-managed="true"></div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--bg-base);border:1px solid var(--border);border-radius:var(--radius-sm)">
<span style="font-size:13px">{{ t('settings.portal.widget_pihole') }}</span>
<div class="toggle" id="portal-widget-pihole" data-managed="true"></div>
</div>
<div id="portal-message" style="display:none"></div>
<div id="portal-status" class="autosave-status"></div>
</div>
Expand Down
Loading
Loading