Files
desktop/custom/apps/pihole/assets/pihole.js
Lars Gebhardt-Kusche ac2d95fc56
All checks were successful
Deploy / deploy-staging (push) Successful in 25s
Deploy / deploy-production (push) Has been skipped
asdasd
2026-06-25 00:17:31 +02:00

489 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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 = `
<div class="modal-card pihole-console-modal" role="dialog" aria-modal="true" aria-labelledby="pihole-console-title">
<div class="modal-header">
<div class="pihole-console-heading">
<strong id="pihole-console-title">Pi-hole Aktion</strong>
<div class="muted" data-pihole-console-subtitle>Status und Rueckmeldungen zur laufenden Aktion.</div>
</div>
<div class="pihole-card-actions">
<button class="pihole-link-button" type="button" data-pihole-console-clear>Protokoll leeren</button>
<button class="icon-button" type="button" data-pihole-console-close aria-label="Fenster schliessen">Schliessen</button>
</div>
</div>
<div class="pihole-console-body" data-pihole-console-body></div>
</div>
`;
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 = `
<div class="pihole-console-meta">
<span>${timestamp}</span>
<span>${tone === 'success' ? 'Erfolg' : tone === 'error' ? 'Fehler' : 'Info'}</span>
</div>
<strong>${message}</strong>
${detail ? `<div class="pihole-console-detail">${detail}</div>` : ''}
`;
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 = `<strong>${label}</strong><span>${fmt.format(value)}</span>`;
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 = `<span>${domain}</span><span class="muted">${instance}</span>`;
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);
}
})();