508 lines
20 KiB
JavaScript
508 lines
20 KiB
JavaScript
(() => {
|
||
const page = document.querySelector('[data-pihole-page]');
|
||
if (!page) return;
|
||
|
||
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;
|
||
|
||
const apiCall = async (action, payload = {}) => {
|
||
const res = await fetch(`/module/pihole/api?action=${encodeURIComponent(action)}`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': '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 setText = (el, value) => {
|
||
if (!el) return;
|
||
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>
|
||
<strong id="pihole-console-title">Pi-hole Aktion</strong>
|
||
<div class="muted">Status und Rueckmeldungen zur laufenden Aktion.</div>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="module-button module-button--secondary module-button--small" type="button" data-pihole-console-clear>Leeren</button>
|
||
<button class="icon-button" type="button" data-pihole-console-close aria-label="Konsole schliessen">×</button>
|
||
</div>
|
||
</div>
|
||
<div class="pihole-console-body" data-pihole-console-body></div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(root);
|
||
|
||
actionConsoleBody = root.querySelector('[data-pihole-console-body]');
|
||
actionConsoleClose = root.querySelector('[data-pihole-console-close]');
|
||
const clearBtn = root.querySelector('[data-pihole-console-clear]');
|
||
|
||
actionConsoleApi = window.NexusModal && typeof window.NexusModal.create === 'function'
|
||
? window.NexusModal.create(root, { initialFocus: '[data-pihole-console-close]' })
|
||
: {
|
||
open() {
|
||
root.classList.add('is-open');
|
||
root.setAttribute('aria-hidden', 'false');
|
||
},
|
||
close() {
|
||
root.classList.remove('is-open');
|
||
root.setAttribute('aria-hidden', 'true');
|
||
},
|
||
};
|
||
|
||
if (actionConsoleClose) {
|
||
actionConsoleClose.addEventListener('click', () => {
|
||
if (!actionInFlight) {
|
||
actionConsoleApi.close();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (clearBtn) {
|
||
clearBtn.addEventListener('click', () => {
|
||
if (actionConsoleBody) {
|
||
actionConsoleBody.innerHTML = '';
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
const appendActionLog = (message, tone = 'info') => {
|
||
ensureActionConsole();
|
||
if (!actionConsoleBody) {
|
||
return;
|
||
}
|
||
const row = document.createElement('div');
|
||
row.className = `pihole-console-line is-${tone}`;
|
||
row.innerHTML = `<span>${new Date().toLocaleTimeString('de-DE')}</span><strong>${message}</strong>`;
|
||
actionConsoleBody.appendChild(row);
|
||
actionConsoleBody.scrollTop = actionConsoleBody.scrollHeight;
|
||
};
|
||
|
||
const setActionLock = (locked, message = 'Bitte warten ...') => {
|
||
actionInFlight = locked;
|
||
page.classList.toggle('is-busy', locked);
|
||
ensureActionConsole();
|
||
if (actionConsoleClose) {
|
||
actionConsoleClose.disabled = locked;
|
||
}
|
||
|
||
page.querySelectorAll('button, input, select, textarea').forEach((el) => {
|
||
const formControl = el;
|
||
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 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 [];
|
||
const entries = Object.entries(map)
|
||
.filter(([, value]) => typeof value === 'number' || typeof value === 'string')
|
||
.map(([key, value]) => [key, Number(value)])
|
||
.sort((a, b) => b[1] - a[1]);
|
||
return entries.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 renderDashboardData = (data) => {
|
||
const summary = data.aggregate?.summary || {};
|
||
setText(document.querySelector('[data-summary-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
|
||
setText(document.querySelector('[data-summary-blocked]'), fmt.format(Number(summary.ads_blocked_today || 0)));
|
||
setText(document.querySelector('[data-summary-percent]'), `${Number(summary.ads_percentage_today || 0).toFixed(2)}%`);
|
||
setText(document.querySelector('[data-summary-clients]'), fmt.format(Number(summary.unique_clients || 0)));
|
||
setStatusBadge(document.querySelector('[data-summary-status]'), summary.status || 'unknown');
|
||
setText(document.querySelector('[data-summary-last-refresh]'), `Letztes Update: ${fmtDate(data.ts)}`);
|
||
|
||
renderInstances(data.instances || {});
|
||
renderList(document.querySelector('[data-query-types]'), data.aggregate?.query_types, 'Keine Daten');
|
||
renderList(document.querySelector('[data-forward-destinations]'), data.aggregate?.forward_destinations, 'Keine Daten');
|
||
renderList(document.querySelector('[data-top-ads]'), data.aggregate?.top_ads, 'Keine Daten');
|
||
renderList(document.querySelector('[data-top-queries]'), data.aggregate?.top_queries, 'Keine Daten');
|
||
renderList(document.querySelector('[data-top-clients]'), data.aggregate?.query_sources, 'Keine Daten');
|
||
renderBlocked(document.querySelector('[data-recent-blocked]'), data.aggregate?.recent_blocked);
|
||
};
|
||
|
||
const dashboardFingerprint = (data) => {
|
||
const aggregate = data?.aggregate?.summary || {};
|
||
const instanceSummary = 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),
|
||
}));
|
||
|
||
return JSON.stringify({
|
||
blocked_domains: Number(aggregate.blocked_domains || 0),
|
||
ads_blocked_today: Number(aggregate.ads_blocked_today || 0),
|
||
unique_domains: Number(aggregate.unique_domains || 0),
|
||
instances: instanceSummary,
|
||
});
|
||
};
|
||
|
||
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);
|
||
const nextFingerprint = dashboardFingerprint(data);
|
||
if (nextFingerprint !== baselineFingerprint) {
|
||
appendActionLog(`Erkennbare Aenderung gefunden. Listen-Update fuer ${instanceLabel} scheint abgeschlossen zu sein.`, 'success');
|
||
return true;
|
||
}
|
||
|
||
if (attempt % 3 === 0) {
|
||
appendActionLog('Noch keine erkennbare Aenderung in den Pi-hole Statusdaten.', 'info');
|
||
}
|
||
} catch (err) {
|
||
appendActionLog(`Statuspruefung fehlgeschlagen: ${err.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
appendActionLog(`Innerhalb des Beobachtungsfensters wurde keine erkennbare Aenderung gefunden. Das Update fuer ${instanceLabel} kann trotzdem weiterlaufen oder bereits ohne sichtbare Statistikaenderung beendet worden sein.`, 'error');
|
||
return false;
|
||
};
|
||
|
||
const renderInstances = (instances) => {
|
||
const holder = document.querySelector('[data-instance-cards]');
|
||
const tpl = document.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)));
|
||
const percent = summary.ads_percentage_today || summary.ads_percentage || 0;
|
||
setText(root.querySelector('[data-instance-percent]'), `${Number(percent).toFixed(2)}%`);
|
||
setStatusBadge(root.querySelector('[data-instance-status]'), summary.status || 'unknown');
|
||
|
||
const actions = root.querySelectorAll('[data-instance]');
|
||
actions.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 (entry.updates && entry.updates.available) {
|
||
updateEl.textContent = 'Updates verfuegbar';
|
||
} else {
|
||
updateEl.textContent = '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 scope = err.scope || 'request';
|
||
const msg = err.error || 'error';
|
||
const hint = err.hint ? `, Hint: ${typeof err.hint === 'string' ? err.hint : JSON.stringify(err.hint)}` : '';
|
||
return `${scope}: ${msg} (${code}${hint})`;
|
||
});
|
||
errorEl.textContent = `API Fehler: ${lines.join(' | ')}`;
|
||
} else {
|
||
errorEl.textContent = '';
|
||
}
|
||
}
|
||
|
||
holder.appendChild(node);
|
||
});
|
||
};
|
||
|
||
const bindActionButtons = () => {
|
||
document.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 scope = btn.closest('.pihole-instance') || document;
|
||
const customInput = scope.querySelector(`[data-custom-minutes="${instance}"]`);
|
||
let payload = { instance };
|
||
let baselineData = null;
|
||
|
||
if (action === 'disable') {
|
||
payload.minutes = Number(minutes || 0);
|
||
}
|
||
if (action === 'disable-custom') {
|
||
payload.minutes = Number(customInput?.value || 0);
|
||
}
|
||
|
||
try {
|
||
const actionLabel = action === 'enable'
|
||
? 'Pi-hole wird aktiviert ...'
|
||
: action === 'disable' || action === 'disable-custom'
|
||
? 'Pi-hole wird deaktiviert ...'
|
||
: action === 'gravity'
|
||
? 'Listen werden aktualisiert ...'
|
||
: 'Aktion wird ausgefuehrt ...';
|
||
ensureActionConsole();
|
||
if (actionConsoleBody) {
|
||
actionConsoleBody.innerHTML = '';
|
||
}
|
||
actionConsoleApi.open();
|
||
appendActionLog(actionLabel, 'info');
|
||
setActionLock(true, actionLabel);
|
||
|
||
baselineData = action === 'gravity' ? await apiCall('dashboard').catch(() => null) : null;
|
||
|
||
if (action === 'enable') {
|
||
appendActionLog(`Aktiviere ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info');
|
||
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(`Deaktiviere ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`} fuer ${payload.minutes} Minuten.`, 'info');
|
||
await apiCall('disable', payload);
|
||
} else if (action === 'gravity') {
|
||
appendActionLog(`Starte Listen-Update fuer ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info');
|
||
await apiCall('gravity', payload);
|
||
const status = document.querySelector('[data-list-update-status]');
|
||
if (status) status.textContent = 'Listen-Update gestartet.';
|
||
} else if (action === 'update') {
|
||
appendActionLog(`Starte Pi-hole-Update fuer ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info');
|
||
await apiCall('update', payload);
|
||
}
|
||
appendActionLog('Aktion abgeschlossen. Dashboard wird aktualisiert.', 'success');
|
||
if (action === 'gravity') {
|
||
await monitorGravityProgress(baselineData, instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`);
|
||
} else {
|
||
await loadDashboard();
|
||
appendActionLog('Anzeige erfolgreich aktualisiert.', 'success');
|
||
}
|
||
} catch (err) {
|
||
appendActionLog(`Aktion fehlgeschlagen: ${err.message}`, 'error');
|
||
if (action === 'gravity' && /timed out/i.test(err.message)) {
|
||
appendActionLog('Der Request ist in Nexus abgelaufen. Pi-hole kann intern trotzdem weiterarbeiten. Starte weiterfuehrende Statuspruefung.', 'info');
|
||
await monitorGravityProgress(baselineData, instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`);
|
||
}
|
||
} finally {
|
||
setActionLock(false);
|
||
}
|
||
});
|
||
};
|
||
|
||
const bindForms = () => {
|
||
const domainForm = document.querySelector('[data-domain-form]');
|
||
if (domainForm) {
|
||
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 = document.querySelector('[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 = document.querySelector('[data-adlist-form]');
|
||
if (adlistForm) {
|
||
adlistForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const formData = new FormData(adlistForm);
|
||
const url = String(formData.get('url') || '').trim();
|
||
const status = document.querySelector('[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 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);
|
||
const existing = page.querySelector('[data-pihole-load-error]');
|
||
if (existing) existing.remove();
|
||
} catch (err) {
|
||
let message = page.querySelector('[data-pihole-load-error]');
|
||
if (!message) {
|
||
message = document.createElement('div');
|
||
message.className = '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;
|
||
}
|
||
};
|
||
|
||
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);
|
||
};
|
||
|
||
bindActionButtons();
|
||
bindForms();
|
||
loadDashboard();
|
||
startAutoRefresh();
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'visible') {
|
||
loadDashboard();
|
||
startAutoRefresh();
|
||
return;
|
||
}
|
||
stopAutoRefresh();
|
||
});
|
||
window.addEventListener('beforeunload', stopAutoRefresh);
|
||
})();
|