New desktop
Some checks failed
Deploy / deploy-staging (push) Failing after 6s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-06 03:20:50 +02:00
parent 756b4e119b
commit 888981c782
127 changed files with 31275 additions and 0 deletions

View File

@@ -0,0 +1,351 @@
.pihole-page .card { background: var(--panel); }
.pihole-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.pihole-stat {
padding: 16px;
background: var(--panel-2);
}
.pihole-stat-value {
font-size: 1.6rem;
font-weight: 700;
margin-top: 6px;
}
.pihole-stat-sub {
margin-top: 4px;
color: var(--muted);
}
.pihole-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pihole-actions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.pihole-inline {
display: inline-flex;
gap: 8px;
align-items: center;
}
.pihole-inline input {
width: 110px;
}
.pihole-instance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
margin-top: 12px;
}
.pihole-instance {
padding: 16px;
background: var(--panel-2);
}
.pihole-instance-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.pihole-instance-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
margin-top: 12px;
}
.pihole-instance-value {
font-weight: 700;
margin-top: 4px;
}
.pihole-status {
padding: 6px 10px;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
background: rgba(0, 179, 164, 0.15);
color: #0a6b63;
}
.pihole-status.is-disabled {
background: rgba(255, 90, 61, 0.15);
color: #a83a28;
}
.pihole-status.is-partial {
background: rgba(255, 166, 0, 0.18);
color: #8a5a00;
}
.pihole-split {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
}
.pihole-list {
display: grid;
gap: 8px;
margin-top: 12px;
}
.pihole-list-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
}
.pihole-list-row strong {
font-weight: 600;
}
.pihole-update {
margin-top: 10px;
font-size: 0.95rem;
color: var(--muted);
}
.pihole-error {
margin-top: 8px;
font-size: 0.9rem;
color: #a83a28;
}
.pihole-blocked {
display: grid;
gap: 8px;
margin-top: 12px;
}
.pihole-blocked-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(255, 90, 61, 0.08);
}
.pihole-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
align-items: end;
margin-top: 12px;
}
.pihole-form label {
display: grid;
gap: 6px;
}
.pihole-page.is-busy {
position: relative;
}
.pihole-busy-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(10, 14, 24, 0.28);
backdrop-filter: blur(2px);
z-index: 80;
}
.pihole-busy-overlay[hidden] {
display: none;
}
.pihole-busy-card {
min-width: min(320px, 92vw);
display: grid;
gap: 8px;
padding: 18px 20px;
border-radius: 16px;
border: 1px solid var(--line);
background: var(--panel);
box-shadow: var(--shadow);
}
.pihole-busy-card span {
color: var(--muted);
}
.pihole-console-modal {
width: min(720px, 96vw);
}
.pihole-console-body {
margin-top: 16px;
display: grid;
gap: 10px;
max-height: 48vh;
overflow: auto;
padding: 4px;
}
.pihole-console-line {
display: grid;
gap: 4px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.6);
}
.pihole-console-line span {
font-size: 0.8rem;
color: var(--muted);
}
.pihole-console-line strong {
font-weight: 600;
}
.pihole-console-line.is-success {
border-color: rgba(0, 179, 164, 0.22);
background: rgba(0, 179, 164, 0.08);
}
.pihole-console-line.is-error {
border-color: rgba(255, 90, 61, 0.24);
background: rgba(255, 90, 61, 0.08);
}
.pihole-instance-card {
padding: 16px;
background: var(--panel-2);
display: grid;
gap: 12px;
}
.pihole-card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pihole-test-result {
margin-top: 6px;
font-size: 0.9rem;
color: var(--muted);
}
.pihole-test-result.is-ok { color: #0a6b63; }
.pihole-test-result.is-auth { color: #a83a28; }
.pihole-test-result.is-unreachable { color: #a83a28; }
.pihole-test-result.is-invalid { color: #8a5a00; }
.pihole-test-result.is-error { color: #a83a28; }
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.form-field {
display: grid;
gap: 6px;
}
.pihole-instance-form {
margin-top: 16px;
display: grid;
gap: 18px;
}
.pihole-instance-form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
}
.pihole-form-field-wide {
grid-column: 1 / -1;
}
.pihole-checkbox-field {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.5);
font-weight: 600;
color: var(--text);
}
.pihole-checkbox-field input {
width: 18px;
height: 18px;
margin: 0;
}
.pihole-modal-card {
width: min(760px, 96vw);
padding: 20px;
}
.pihole-modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
padding-top: 4px;
}
.icon-button {
border: 1px solid var(--line);
background: var(--panel);
border-radius: 10px;
width: 36px;
height: 36px;
cursor: pointer;
font-size: 1.1rem;
}
@media (max-width: 680px) {
.pihole-actions {
flex-direction: column;
align-items: stretch;
}
.pihole-inline {
width: 100%;
}
.pihole-inline input {
width: 100%;
}
.pihole-instance-form-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,507 @@
(() => {
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);
})();

View File

@@ -0,0 +1,141 @@
(() => {
const modal = document.querySelector('[data-instance-modal]');
const form = document.querySelector('[data-instance-form]');
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}`);
return data;
};
document.addEventListener('click', async (event) => {
const testBtn = event.target.closest('[data-instance-test]');
if (!testBtn) return;
const card = testBtn.closest('[data-instance-id]');
if (!card) return;
const instanceId = card.dataset.instanceId || '';
const resultEl = card.querySelector('[data-instance-result]');
if (resultEl) {
resultEl.classList.remove('is-ok', 'is-auth', 'is-unreachable', 'is-invalid', 'is-error');
resultEl.textContent = 'Teste Verbindung...';
}
try {
const res = await apiCall('test', { instance: instanceId });
if (resultEl) {
const statusClass = res.status ? `is-${res.status}` : 'is-ok';
resultEl.classList.add(statusClass);
resultEl.textContent = res.message || 'Verbindung OK.';
}
} catch (err) {
if (resultEl) {
const msg = err.message || 'Fehler';
resultEl.classList.add('is-error');
resultEl.textContent = `Test fehlgeschlagen: ${msg}`;
}
}
});
if (!modal || !form) return;
const title = document.querySelector('[data-instance-modal-title]');
const closeBtn = document.querySelector('[data-instance-close]');
const newBtn = document.querySelector('[data-instance-new]');
const cancelBtn = document.querySelector('[data-instance-cancel]');
const currentIdInput = form.querySelector('input[name="current_id"]');
const idInput = form.querySelector('input[name="instance_id"]');
const nameInput = form.querySelector('input[name="name"]');
const urlInput = form.querySelector('input[name="url"]');
const passwordInput = form.querySelector('input[name="password"]');
const primaryInput = form.querySelector('input[name="is_primary"]');
const resetForm = () => {
if (currentIdInput) currentIdInput.value = '';
if (idInput) idInput.value = '';
if (nameInput) nameInput.value = '';
if (urlInput) urlInput.value = '';
if (passwordInput) passwordInput.value = '';
if (primaryInput) primaryInput.checked = false;
};
const modalApi = window.NexusModal && typeof window.NexusModal.create === 'function'
? window.NexusModal.create(modal, { initialFocus: 'input[name="instance_id"]' })
: null;
const openModal = () => {
if (modalApi) {
modalApi.open();
return;
}
modal.classList.add('is-open');
modal.setAttribute('aria-hidden', 'false');
if (idInput) {
window.setTimeout(() => idInput.focus(), 20);
}
};
const closeModal = () => {
if (modalApi) {
modalApi.close();
return;
}
modal.classList.remove('is-open');
modal.setAttribute('aria-hidden', 'true');
};
document.querySelectorAll('[data-instance-edit]').forEach((btn) => {
btn.addEventListener('click', () => {
const card = btn.closest('[data-instance-id]');
if (!card) return;
if (currentIdInput) currentIdInput.value = card.dataset.instanceId || '';
if (idInput) idInput.value = card.dataset.instanceId || '';
if (nameInput) nameInput.value = card.dataset.name || '';
if (urlInput) urlInput.value = card.dataset.url || '';
if (passwordInput) passwordInput.value = '';
if (primaryInput) primaryInput.checked = card.dataset.primary === '1';
if (title) title.textContent = 'Instanz bearbeiten';
openModal();
});
});
if (newBtn) {
newBtn.addEventListener('click', () => {
resetForm();
if (title) title.textContent = 'Neue Instanz';
openModal();
});
}
if (closeBtn) {
closeBtn.addEventListener('click', () => closeModal());
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
resetForm();
closeModal();
});
}
if (!modalApi) {
modal.addEventListener('click', (event) => {
if (event.target === modal) {
closeModal();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && modal.classList.contains('is-open')) {
closeModal();
}
});
}
})();