Pihole
This commit is contained in:
121
modules/pihole/assets/pihole-native.js
Normal file
121
modules/pihole/assets/pihole-native.js
Normal file
@@ -0,0 +1,121 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function toPartialUrl(input, fallbackView) {
|
||||
const url = new URL(input, window.location.origin);
|
||||
if (!url.searchParams.has('view') && fallbackView) {
|
||||
url.searchParams.set('view', fallbackView);
|
||||
}
|
||||
url.searchParams.set('partial', '1');
|
||||
return url;
|
||||
}
|
||||
|
||||
async function fetchHtml(url, options) {
|
||||
const response = await fetch(url.toString(), {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'text/html',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function enhanceHost(host, state) {
|
||||
host.querySelectorAll('a[href]').forEach((link) => {
|
||||
const href = link.getAttribute('href') || '';
|
||||
if (!href.startsWith('/apps/pihole')) {
|
||||
return;
|
||||
}
|
||||
|
||||
link.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
state.load(href);
|
||||
});
|
||||
});
|
||||
|
||||
host.querySelectorAll('form').forEach((form) => {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const method = String(form.getAttribute('method') || 'get').toUpperCase();
|
||||
const action = form.getAttribute('action') || state.currentUrl.toString();
|
||||
const formData = new FormData(form);
|
||||
const url = toPartialUrl(action, state.defaultView);
|
||||
|
||||
try {
|
||||
if (method === 'GET') {
|
||||
const next = new URL(url.toString());
|
||||
next.search = '';
|
||||
next.searchParams.set('partial', '1');
|
||||
for (const [key, value] of formData.entries()) {
|
||||
next.searchParams.append(key, String(value));
|
||||
}
|
||||
await state.load(next.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
const html = await fetchHtml(url, {
|
||||
method,
|
||||
body: formData
|
||||
});
|
||||
state.render(html, url);
|
||||
} catch (error) {
|
||||
state.renderError(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initContent(host) {
|
||||
if (typeof window.initPiholeModule === 'function') {
|
||||
window.initPiholeModule(host);
|
||||
}
|
||||
if (typeof window.initPiholeInstancesModule === 'function') {
|
||||
window.initPiholeInstancesModule(host);
|
||||
}
|
||||
}
|
||||
|
||||
window.initPiholeApp = function initPiholeApp(host, options) {
|
||||
if (!(host instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryRoute = String(options?.entryRoute || '/apps/pihole/index.php');
|
||||
const defaultView = String(options?.defaultView || 'dashboard');
|
||||
|
||||
const state = {
|
||||
defaultView,
|
||||
currentUrl: toPartialUrl(entryRoute, defaultView),
|
||||
async load(targetUrl) {
|
||||
const url = toPartialUrl(targetUrl, defaultView);
|
||||
this.currentUrl = url;
|
||||
host.innerHTML = '<div class="window-app-loading"><p>Pi-hole wird geladen...</p></div>';
|
||||
try {
|
||||
const html = await fetchHtml(url);
|
||||
this.render(html, url);
|
||||
} catch (error) {
|
||||
this.renderError(error);
|
||||
}
|
||||
},
|
||||
render(html, url) {
|
||||
this.currentUrl = url;
|
||||
host.innerHTML = html;
|
||||
enhanceHost(host, this);
|
||||
initContent(host);
|
||||
},
|
||||
renderError(error) {
|
||||
const message = error instanceof Error ? error.message : 'Pi-hole konnte nicht geladen werden.';
|
||||
host.innerHTML = `<div class="window-app-error-state"><p>${message}</p></div>`;
|
||||
}
|
||||
};
|
||||
|
||||
state.load(entryRoute);
|
||||
};
|
||||
})();
|
||||
391
modules/pihole/assets/pihole.css
Normal file
391
modules/pihole/assets/pihole.css
Normal file
@@ -0,0 +1,391 @@
|
||||
.pihole-frame {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pihole-panel-shell {
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.pihole-content {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.pihole-card,
|
||||
.module-box,
|
||||
.module-box-soft {
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.pihole-message {
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.pihole-message--error {
|
||||
color: #991b1b;
|
||||
background: rgba(254, 226, 226, 0.9);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
}
|
||||
|
||||
.pihole-message--success {
|
||||
color: #166534;
|
||||
background: rgba(220, 252, 231, 0.9);
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
|
||||
.pihole-grid,
|
||||
.module-box-grid--stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pihole-stat {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.pihole-stat-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.pihole-stat-sub {
|
||||
margin-top: 4px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pihole-section-header,
|
||||
.module-box-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pihole-section-title,
|
||||
.module-box-title {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.pihole-actions,
|
||||
.pihole-card-actions,
|
||||
.pihole-modal-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pihole-primary-action,
|
||||
.cta-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #14b8a6, #0f766e);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pihole-link-button,
|
||||
.nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.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,
|
||||
.pihole-instance-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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(20, 184, 166, 0.15);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.pihole-status.is-disabled {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.pihole-status.is-partial {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.pihole-split,
|
||||
.module-box-grid--panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pihole-list,
|
||||
.pihole-blocked {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-list-row,
|
||||
.pihole-blocked-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(248, 250, 252, 0.95);
|
||||
}
|
||||
|
||||
.pihole-blocked-row {
|
||||
background: rgba(254, 242, 242, 0.9);
|
||||
}
|
||||
|
||||
.pihole-update {
|
||||
margin-top: 10px;
|
||||
font-size: 0.95rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pihole-error {
|
||||
margin-top: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.pihole-form,
|
||||
.pihole-setup-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-form {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.pihole-form label,
|
||||
.pihole-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pihole-field input,
|
||||
.pihole-field select,
|
||||
.pihole-form input,
|
||||
.pihole-form select,
|
||||
.pihole-form textarea,
|
||||
.pihole-instance-form input {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.pihole-setup-grid,
|
||||
.pihole-instance-form-grid,
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pihole-form-field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.pihole-checkbox-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pihole-checkbox-field input {
|
||||
width: 18px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.pihole-page.is-busy {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(15, 23, 42, 0.24);
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.modal.is-open,
|
||||
.modal[aria-hidden="false"] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(720px, 96vw);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 28px 64px rgba(15, 23, 42, 0.18);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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 rgba(148, 163, 184, 0.24);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.pihole-console-line span {
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pihole-console-line strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pihole-console-line.is-success {
|
||||
border-color: rgba(20, 184, 166, 0.22);
|
||||
background: rgba(20, 184, 166, 0.08);
|
||||
}
|
||||
|
||||
.pihole-console-line.is-error {
|
||||
border-color: rgba(239, 68, 68, 0.24);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.pihole-test-result {
|
||||
margin-top: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pihole-test-result.is-ok { color: #0f766e; }
|
||||
.pihole-test-result.is-auth,
|
||||
.pihole-test-result.is-unreachable,
|
||||
.pihole-test-result.is-error { color: #b91c1c; }
|
||||
.pihole-test-result.is-invalid { color: #92400e; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.pihole-frame {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pihole-actions,
|
||||
.pihole-card-actions,
|
||||
.pihole-modal-actions,
|
||||
.pihole-inline {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.pihole-inline input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
448
modules/pihole/assets/pihole.js
Normal file
448
modules/pihole/assets/pihole.js
Normal file
@@ -0,0 +1,448 @@
|
||||
(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;
|
||||
|
||||
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>
|
||||
<strong id="pihole-console-title">Pi-hole Aktion</strong>
|
||||
<div class="muted">Status und Rueckmeldungen zur laufenden Aktion.</div>
|
||||
</div>
|
||||
<div class="pihole-card-actions">
|
||||
<button class="pihole-link-button" 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 = {
|
||||
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 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) => {
|
||||
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 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 = '';
|
||||
actionConsoleApi.open();
|
||||
setActionLock(true);
|
||||
|
||||
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 = query('[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();
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
})();
|
||||
121
modules/pihole/assets/pihole_instances.js
Normal file
121
modules/pihole/assets/pihole_instances.js
Normal file
@@ -0,0 +1,121 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
window.initPiholeInstancesModule = function initPiholeInstancesModule(rootNode) {
|
||||
const scope = rootNode instanceof Element ? rootNode : document;
|
||||
const modal = scope.querySelector('[data-instance-modal]');
|
||||
const form = scope.querySelector('[data-instance-form]');
|
||||
|
||||
scope.querySelectorAll('[data-instance-test]').forEach((button) => {
|
||||
if (button.dataset.piholeBound === '1') return;
|
||||
button.dataset.piholeBound = '1';
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
const card = button.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 fetch(`/api/pihole/index.php?action=test`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ instance: instanceId }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
if (resultEl) {
|
||||
resultEl.classList.add(data.status ? `is-${data.status}` : 'is-ok');
|
||||
resultEl.textContent = data.message || 'Verbindung OK.';
|
||||
}
|
||||
} catch (err) {
|
||||
if (resultEl) {
|
||||
resultEl.classList.add('is-error');
|
||||
resultEl.textContent = `Test fehlgeschlagen: ${err.message || 'Fehler'}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!modal || !form || modal.dataset.piholeBound === '1') return;
|
||||
modal.dataset.piholeBound = '1';
|
||||
|
||||
const title = scope.querySelector('[data-instance-modal-title]');
|
||||
const closeBtn = scope.querySelector('[data-instance-close]');
|
||||
const newBtn = scope.querySelector('[data-instance-new]');
|
||||
const cancelBtn = scope.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 openModal = () => {
|
||||
modal.classList.add('is-open');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
if (idInput) window.setTimeout(() => idInput.focus(), 20);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
modal.classList.remove('is-open');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
};
|
||||
|
||||
scope.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();
|
||||
});
|
||||
});
|
||||
|
||||
newBtn?.addEventListener('click', () => {
|
||||
resetForm();
|
||||
if (title) title.textContent = 'Neue Instanz';
|
||||
openModal();
|
||||
});
|
||||
|
||||
closeBtn?.addEventListener('click', closeModal);
|
||||
cancelBtn?.addEventListener('click', () => {
|
||||
resetForm();
|
||||
closeModal();
|
||||
});
|
||||
|
||||
modal.addEventListener('click', (event) => {
|
||||
if (event.target === modal) closeModal();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && modal.classList.contains('is-open')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (document.querySelector('[data-instance-modal]')) {
|
||||
window.initPiholeInstancesModule(document);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user