(function () { 'use strict'; window.initPiholeModule = function initPiholeModule(rootNode) { const scope = rootNode instanceof Element ? rootNode : document; const page = scope.matches?.('[data-pihole-page]') ? scope : scope.querySelector('[data-pihole-page]'); if (!page || page.dataset.piholeBound === '1') return; page.dataset.piholeBound = '1'; const pageType = page.dataset.piholePage || 'dashboard'; const configuredRefreshSeconds = Number(page.dataset.refreshSeconds || 0); const defaultRefreshSeconds = pageType === 'dashboard' ? 1 : 5; const refreshSeconds = Number.isFinite(configuredRefreshSeconds) && configuredRefreshSeconds >= 0 ? configuredRefreshSeconds : defaultRefreshSeconds; const fmt = new Intl.NumberFormat('de-DE'); const fmtDate = (ts) => { if (!ts) return '–'; const d = new Date(ts * 1000); return d.toLocaleString('de-DE'); }; let refreshTimer = null; let loadInFlight = false; let actionInFlight = false; let actionConsoleApi = null; let actionConsoleBody = null; let actionConsoleClose = null; let actionConsoleTitle = null; let actionConsoleSubtitle = null; const apiCall = async (action, payload = {}) => { const res = await fetch(`/api/pihole/index.php?action=${encodeURIComponent(action)}`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(payload), }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.error || `HTTP ${res.status}`); } if (data && data.results && typeof data.results === 'object') { const failures = Object.values(data.results).filter((row) => row && row.ok === false); if (failures.length) { const first = failures[0]; throw new Error(first.error || 'action_failed'); } } return data; }; const query = (selector) => page.querySelector(selector); const setText = (el, value) => { if (el) el.textContent = value; }; const ensureActionConsole = () => { if (actionConsoleApi && actionConsoleBody && actionConsoleClose) return; const root = document.createElement('div'); root.className = 'modal'; root.dataset.piholeConsoleModal = '1'; root.setAttribute('aria-hidden', 'true'); root.innerHTML = ` `; document.body.appendChild(root); actionConsoleTitle = root.querySelector('#pihole-console-title'); actionConsoleSubtitle = root.querySelector('[data-pihole-console-subtitle]'); actionConsoleBody = root.querySelector('[data-pihole-console-body]'); actionConsoleClose = root.querySelector('[data-pihole-console-close]'); const clearBtn = root.querySelector('[data-pihole-console-clear]'); actionConsoleApi = { open() { root.classList.add('is-open'); root.setAttribute('aria-hidden', 'false'); }, close() { root.classList.remove('is-open'); root.setAttribute('aria-hidden', 'true'); } }; actionConsoleClose?.addEventListener('click', () => { if (!actionInFlight) actionConsoleApi.close(); }); clearBtn?.addEventListener('click', () => { if (actionConsoleBody) actionConsoleBody.innerHTML = ''; }); }; const setActionConsoleMeta = (title, subtitle) => { ensureActionConsole(); if (actionConsoleTitle) actionConsoleTitle.textContent = title || 'Pi-hole Aktion'; if (actionConsoleSubtitle) actionConsoleSubtitle.textContent = subtitle || 'Status und Rueckmeldungen zur laufenden Aktion.'; }; const appendActionLog = (message, tone = 'info', detail = '') => { ensureActionConsole(); if (!actionConsoleBody) return; const row = document.createElement('div'); row.className = `pihole-console-line is-${tone}`; const timestamp = new Date().toLocaleString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); row.innerHTML = `
${timestamp} ${tone === 'success' ? 'Erfolg' : tone === 'error' ? 'Fehler' : 'Info'}
${message} ${detail ? `
${detail}
` : ''} `; actionConsoleBody.appendChild(row); actionConsoleBody.scrollTop = actionConsoleBody.scrollHeight; }; const setActionLock = (locked) => { actionInFlight = locked; page.classList.toggle('is-busy', locked); ensureActionConsole(); if (actionConsoleClose) { actionConsoleClose.disabled = locked; } page.querySelectorAll('button, input, select, textarea').forEach((formControl) => { if (locked) { formControl.dataset.piholeWasDisabled = formControl.disabled ? 'true' : 'false'; formControl.disabled = true; return; } formControl.disabled = formControl.dataset.piholeWasDisabled === 'true'; delete formControl.dataset.piholeWasDisabled; }); }; const statusLabel = (status) => { if (status === 'enabled') return 'Aktiv'; if (status === 'disabled') return 'Deaktiviert'; if (status === 'partial') return 'Teilweise'; return 'Unbekannt'; }; const actionLabel = (action) => { if (action === 'enable') return 'Pi-hole aktivieren'; if (action === 'disable' || action === 'disable-custom') return 'Pi-hole temporaer deaktivieren'; if (action === 'gravity') return 'Listen aktualisieren'; if (action === 'update') return 'Pi-hole aktualisieren'; return 'Pi-hole Aktion'; }; const targetLabel = (instance) => { if (!instance || instance === 'all') return 'alle Instanzen'; if (instance === 'primary') return 'die Primaer-Instanz'; return `Instanz ${instance}`; }; const setStatusBadge = (el, status) => { if (!el) return; el.textContent = statusLabel(status); el.classList.remove('is-disabled', 'is-partial'); if (status === 'disabled') el.classList.add('is-disabled'); if (status === 'partial') el.classList.add('is-partial'); }; const mapToList = (map, limit = 10) => { if (!map || typeof map !== 'object') return []; return Object.entries(map) .filter(([, value]) => typeof value === 'number' || typeof value === 'string') .map(([key, value]) => [key, Number(value)]) .sort((a, b) => b[1] - a[1]) .slice(0, limit); }; const renderList = (container, map, emptyText) => { if (!container) return; const rows = mapToList(map, 10); container.innerHTML = ''; if (!rows.length) { const div = document.createElement('div'); div.className = 'muted'; div.textContent = emptyText || 'Keine Daten'; container.appendChild(div); return; } rows.forEach(([label, value]) => { const row = document.createElement('div'); row.className = 'pihole-list-row'; row.innerHTML = `${label}${fmt.format(value)}`; container.appendChild(row); }); }; const renderBlocked = (container, list) => { if (!container) return; container.innerHTML = ''; if (!Array.isArray(list) || !list.length) { const div = document.createElement('div'); div.className = 'muted'; div.textContent = 'Keine Daten'; container.appendChild(div); return; } list.slice(0, 20).forEach((entry) => { const domain = entry.domain || entry; const instance = entry.instance || ''; const row = document.createElement('div'); row.className = 'pihole-blocked-row'; row.innerHTML = `${domain}${instance}`; container.appendChild(row); }); }; const renderInstances = (instances) => { const holder = query('[data-instance-cards]'); const tpl = scope.querySelector('#pihole-instance-template'); if (!holder || !tpl) return; holder.innerHTML = ''; Object.values(instances).forEach((entry) => { const node = tpl.content.cloneNode(true); const root = node.querySelector('[data-instance]'); if (!root) return; root.dataset.instance = entry.meta.id; const summary = entry.summary || {}; setText(root.querySelector('[data-instance-name]'), entry.meta.name || entry.meta.id); setText(root.querySelector('[data-instance-url]'), entry.meta.url || ''); setText(root.querySelector('[data-instance-dns]'), fmt.format(Number(summary.dns_queries_today || 0))); setText(root.querySelector('[data-instance-ads]'), fmt.format(Number(summary.ads_blocked_today || 0))); setText(root.querySelector('[data-instance-percent]'), `${Number(summary.ads_percentage_today || summary.ads_percentage || 0).toFixed(2)}%`); setStatusBadge(root.querySelector('[data-instance-status]'), summary.status || 'unknown'); root.querySelectorAll('[data-instance]').forEach((btn) => { if (btn.dataset.instance === '') btn.dataset.instance = entry.meta.id; }); const customInput = root.querySelector('[data-custom-minutes]'); if (customInput) customInput.dataset.customMinutes = entry.meta.id; const updateEl = root.querySelector('[data-instance-update]'); if (updateEl) { updateEl.textContent = entry.updates && entry.updates.available ? 'Updates verfuegbar' : 'Keine Updates erkannt'; } const errorEl = root.querySelector('[data-instance-errors]'); if (errorEl) { if (Array.isArray(entry.errors) && entry.errors.length) { const lines = entry.errors.map((err) => { const code = err.http_code ? `HTTP ${err.http_code}` : 'HTTP ?'; const scopeName = err.scope || 'request'; const msg = err.error || 'error'; const hint = err.hint ? `, Hint: ${typeof err.hint === 'string' ? err.hint : JSON.stringify(err.hint)}` : ''; return `${scopeName}: ${msg} (${code}${hint})`; }); errorEl.textContent = `API Fehler: ${lines.join(' | ')}`; } else { errorEl.textContent = ''; } } holder.appendChild(node); }); }; const renderDashboardData = (data) => { const summary = data.aggregate?.summary || {}; setText(query('[data-summary-dns]'), fmt.format(Number(summary.dns_queries_today || 0))); setText(query('[data-summary-blocked]'), fmt.format(Number(summary.ads_blocked_today || 0))); setText(query('[data-summary-percent]'), `${Number(summary.ads_percentage_today || 0).toFixed(2)}%`); setText(query('[data-summary-clients]'), fmt.format(Number(summary.unique_clients || 0))); setStatusBadge(query('[data-summary-status]'), summary.status || 'unknown'); setText(query('[data-summary-last-refresh]'), `Letztes Update: ${fmtDate(data.ts)}`); renderInstances(data.instances || {}); renderList(query('[data-query-types]'), data.aggregate?.query_types, 'Keine Daten'); renderList(query('[data-forward-destinations]'), data.aggregate?.forward_destinations, 'Keine Daten'); renderList(query('[data-top-ads]'), data.aggregate?.top_ads, 'Keine Daten'); renderList(query('[data-top-queries]'), data.aggregate?.top_queries, 'Keine Daten'); renderList(query('[data-top-clients]'), data.aggregate?.query_sources, 'Keine Daten'); renderBlocked(query('[data-recent-blocked]'), data.aggregate?.recent_blocked); }; const dashboardFingerprint = (data) => JSON.stringify({ blocked_domains: Number(data?.aggregate?.summary?.blocked_domains || 0), ads_blocked_today: Number(data?.aggregate?.summary?.ads_blocked_today || 0), unique_domains: Number(data?.aggregate?.summary?.unique_domains || 0), instances: Object.values(data?.instances || {}).map((entry) => ({ id: entry?.meta?.id || '', blocked_domains: Number(entry?.summary?.blocked_domains || 0), ads_blocked_today: Number(entry?.summary?.ads_blocked_today || 0), unique_domains: Number(entry?.summary?.unique_domains || 0), })), }); const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); const monitorGravityProgress = async (baselineData, instanceLabel) => { const baselineFingerprint = dashboardFingerprint(baselineData || {}); const maxPolls = 24; const pollDelayMs = 5000; appendActionLog(`Pi-hole meldet keinen Live-Fortschritt. Pruefe jetzt zyklisch auf erkennbare Aenderungen fuer ${instanceLabel}.`, 'info'); for (let attempt = 1; attempt <= maxPolls; attempt += 1) { await delay(pollDelayMs); appendActionLog(`Pruefung ${attempt}/${maxPolls}: aktuelle Statusdaten werden geladen ...`, 'info'); try { const data = await apiCall('dashboard'); if (!data.ok) throw new Error(data.error || 'API error'); renderDashboardData(data); if (dashboardFingerprint(data) !== baselineFingerprint) { appendActionLog(`Erkennbare Aenderung gefunden. Listen-Update fuer ${instanceLabel} scheint abgeschlossen zu sein.`, 'success'); return true; } } catch (err) { appendActionLog(`Statuspruefung fehlgeschlagen: ${err.message}`, 'error'); } } appendActionLog(`Innerhalb des Beobachtungsfensters wurde keine erkennbare Aenderung gefunden.`, 'error'); return false; }; const loadDashboard = async () => { if (loadInFlight || actionInFlight) return; loadInFlight = true; try { const data = await apiCall('dashboard'); if (!data.ok) throw new Error(data.error || 'API error'); renderDashboardData(data); query('[data-pihole-load-error]')?.remove(); } catch (err) { let message = query('[data-pihole-load-error]'); if (!message) { message = document.createElement('div'); message.className = 'window-app-card pihole-card'; message.style.marginTop = '1rem'; message.dataset.piholeLoadError = '1'; page.appendChild(message); } message.textContent = `Fehler beim Laden der Pi-hole Daten: ${err.message}`; } finally { loadInFlight = false; } }; page.addEventListener('click', async (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; const instance = btn.dataset.instance || 'all'; const minutes = btn.dataset.minutes; const buttonScope = btn.closest('.pihole-instance') || page; const customInput = buttonScope.querySelector(`[data-custom-minutes="${instance}"]`); const payload = { instance }; let baselineData = null; if (action === 'disable') payload.minutes = Number(minutes || 0); if (action === 'disable-custom') payload.minutes = Number(customInput?.value || 0); try { ensureActionConsole(); if (actionConsoleBody) actionConsoleBody.innerHTML = ''; setActionConsoleMeta(actionLabel(action), `Rueckmeldungen fuer ${targetLabel(instance)}.`); actionConsoleApi.open(); setActionLock(true); baselineData = action === 'gravity' ? await apiCall('dashboard').catch(() => null) : null; if (action === 'enable') { appendActionLog('Aktivierung wird ausgefuehrt.', 'info', `Ziel: ${targetLabel(instance)}`); await apiCall('enable', payload); } else if (action === 'disable' || action === 'disable-custom') { if (!payload.minutes || payload.minutes <= 0) { appendActionLog('Fehler: Bitte Minuten angeben.', 'error'); return; } appendActionLog('Temporaere Deaktivierung wird ausgefuehrt.', 'info', `Ziel: ${targetLabel(instance)} | Dauer: ${payload.minutes} Minuten`); await apiCall('disable', payload); } else if (action === 'gravity') { appendActionLog('Listen-Update wurde gestartet.', 'info', `Ziel: ${targetLabel(instance)}`); await apiCall('gravity', payload); const status = query('[data-list-update-status]'); if (status) status.textContent = 'Listen-Update gestartet.'; } else if (action === 'update') { appendActionLog('Pi-hole-Update wurde gestartet.', 'info', `Ziel: ${targetLabel(instance)}`); await apiCall('update', payload); } appendActionLog('Aktion erfolgreich abgeschlossen.', 'success', 'Dashboard wird jetzt aktualisiert.'); if (action === 'gravity') { await monitorGravityProgress(baselineData, targetLabel(instance)); } else { await loadDashboard(); } } catch (err) { appendActionLog(`Aktion fehlgeschlagen: ${err.message}`, 'error'); } finally { setActionLock(false); } }); const domainForm = query('[data-domain-form]'); domainForm?.addEventListener('submit', async (e) => { e.preventDefault(); const formData = new FormData(domainForm); const type = String(formData.get('type') || 'block'); const domain = String(formData.get('domain') || '').trim(); const status = query('[data-domain-status]'); if (!domain) return; try { await apiCall('domain_add', { type, domain, instance: 'primary' }); if (status) status.textContent = `Domain hinzugefuegt: ${domain}`; domainForm.reset(); } catch (err) { if (status) status.textContent = `Fehler: ${err.message}`; } }); const adlistForm = query('[data-adlist-form]'); adlistForm?.addEventListener('submit', async (e) => { e.preventDefault(); const formData = new FormData(adlistForm); const url = String(formData.get('url') || '').trim(); const status = query('[data-adlist-status]'); if (!url) return; try { await apiCall('adlist_add', { url, instance: 'primary' }); if (status) status.textContent = `Adlist hinzugefuegt: ${url}`; adlistForm.reset(); } catch (err) { if (status) status.textContent = `Fehler: ${err.message}`; } }); const stopAutoRefresh = () => { if (refreshTimer !== null) { window.clearInterval(refreshTimer); refreshTimer = null; } }; const startAutoRefresh = () => { stopAutoRefresh(); if (refreshSeconds <= 0 || document.visibilityState !== 'visible') return; refreshTimer = window.setInterval(loadDashboard, refreshSeconds * 1000); }; loadDashboard(); startAutoRefresh(); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { loadDashboard(); startAutoRefresh(); } else { stopAutoRefresh(); } }); window.addEventListener('beforeunload', stopAutoRefresh); }; if (document.querySelector('[data-pihole-page]')) { window.initPiholeModule(document); } })();