Pihole
All checks were successful
Deploy / deploy-staging (push) Successful in 24s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-24 00:18:11 +02:00
parent 0b06cae679
commit 80874e2e1d
19 changed files with 3465 additions and 0 deletions

1166
modules/pihole/api/index.php Normal file

File diff suppressed because it is too large Load Diff

View 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);
};
})();

View 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%;
}
}

View 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);
}
})();

View 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);
}
})();

View File

@@ -0,0 +1,116 @@
<?php
use App\ModuleConfigException;
$moduleName = 'pihole';
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
$mm->registerFunction($moduleName, 'settings', function () use ($moduleName): array {
return modules()->settings($moduleName);
});
$mm->registerFunction($moduleName, 'instances', function () use ($moduleName): array {
$settings = modules()->settings($moduleName);
$apiPath = trim((string)($settings['api_path'] ?? '/admin/api.php'));
if ($apiPath === '') {
$apiPath = '/admin/api.php';
}
if ($apiPath[0] !== '/') {
$apiPath = '/' . $apiPath;
}
$timeout = (int)($settings['api_timeout_sec'] ?? 8);
if ($timeout <= 0) {
$timeout = 8;
}
$verifyTls = !isset($settings['verify_tls']) || $settings['verify_tls'] === '1' || $settings['verify_tls'] === 1 || $settings['verify_tls'] === true;
$normalizeSecret = static function (array $row): string {
$password = trim((string)($row['password'] ?? ''));
if ($password !== '') {
return $password;
}
return trim((string)($row['token'] ?? ''));
};
$instances = [];
$rawJson = trim((string)($settings['instances_json'] ?? ''));
if ($rawJson !== '') {
$decoded = json_decode($rawJson, true);
if (is_array($decoded)) {
foreach ($decoded as $row) {
if (!is_array($row)) {
continue;
}
$id = trim((string)($row['id'] ?? ''));
$url = trim((string)($row['url'] ?? ''));
if ($id === '' || $url === '') {
continue;
}
$instances[$id] = [
'id' => $id,
'name' => trim((string)($row['name'] ?? '')) ?: $id,
'url' => rtrim($url, '/'),
'password' => $normalizeSecret($row),
'api_path' => $apiPath,
'timeout' => $timeout,
'verify_tls' => $verifyTls,
'is_primary' => !empty($row['is_primary']),
];
}
}
}
if (empty($instances)) {
foreach (['primary', 'secondary'] as $key) {
$urlKey = $key . '_url';
$tokenKey = $key . '_token';
$nameKey = $key . '_name';
$url = trim((string)($settings[$urlKey] ?? ''));
if ($url === '') {
continue;
}
$instances[$key] = [
'id' => $key,
'name' => trim((string)($settings[$nameKey] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'),
'url' => rtrim($url, '/'),
'password' => trim((string)($settings[$tokenKey] ?? '')),
'api_path' => $apiPath,
'timeout' => $timeout,
'verify_tls' => $verifyTls,
'is_primary' => $key === 'primary',
];
}
}
$primaryId = trim((string)($settings['primary_id'] ?? ''));
if ($primaryId !== '' && isset($instances[$primaryId])) {
foreach ($instances as $id => &$row) {
$row['is_primary'] = ($id === $primaryId);
}
unset($row);
} else {
$hasPrimary = false;
foreach ($instances as $row) {
if (!empty($row['is_primary'])) {
$hasPrimary = true;
break;
}
}
if (!$hasPrimary && $instances) {
$firstKey = array_key_first($instances);
if ($firstKey !== null) {
$instances[$firstKey]['is_primary'] = true;
}
}
}
return $instances;
});
$mm->registerFunction($moduleName, 'lists_primary_only', function () use ($moduleName): bool {
$settings = modules()->settings($moduleName);
return !empty($settings['lists_primary_only']);
});

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
return [
'api_path' => '/admin/api.php',
'api_timeout_sec' => 8,
'action_timeout_sec' => 120,
'dashboard_refresh_sec' => 1,
'lists_refresh_sec' => 5,
'queries_refresh_sec' => 5,
'verify_tls' => true,
'lists_primary_only' => false,
'instances_json' => '',
'primary_id' => '',
];

View File

@@ -0,0 +1,16 @@
{
"eyebrow": "Modul",
"title": "Pi-hole",
"description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.",
"actions": [
{ "label": "Setup", "href": "/apps/pihole/index.php?view=setup", "variant": "ghost" },
{ "label": "Instanzen", "href": "/apps/pihole/index.php?view=instances", "variant": "secondary" }
],
"tabs": [
{ "label": "Dashboard", "href": "/apps/pihole/index.php?view=dashboard", "view": "dashboard" },
{ "label": "Listen", "href": "/apps/pihole/index.php?view=lists", "view": "lists" },
{ "label": "Queries", "href": "/apps/pihole/index.php?view=queries", "view": "queries" },
{ "label": "Instanzen", "href": "/apps/pihole/index.php?view=instances", "view": "instances" },
{ "label": "Setup", "href": "/apps/pihole/index.php?view=setup", "view": "setup" }
]
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
$manifest = [];
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
if ($manifestRaw !== false) {
$decoded = json_decode($manifestRaw, true);
$manifest = is_array($decoded) ? $decoded : [];
}
return [
'app_id' => 'pihole',
'title' => (string) ($manifest['title'] ?? 'Pi-hole'),
'icon' => 'PH',
'entry_route' => '/apps/pihole/index.php',
'window_mode' => 'window',
'content_mode' => 'native-module',
'default_width' => 1220,
'default_height' => 860,
'supports_widget' => false,
'supports_tray' => false,
'module_name' => 'network',
'summary' => (string) ($manifest['description'] ?? 'Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.'),
'native_module' => [
'initializer' => 'initPiholeApp',
'options' => [
'entryRoute' => '/apps/pihole/index.php',
'defaultView' => 'dashboard',
],
'assets' => [
'styles' => [
['href' => '/module-assets/index.php?module=pihole&path=assets/pihole.css&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole.css') ? filemtime(__DIR__ . '/assets/pihole.css') : '0'))],
],
'scripts' => [
['src' => '/module-assets/index.php?module=pihole&path=assets/pihole.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole.js') ? filemtime(__DIR__ . '/assets/pihole.js') : '0'))],
['src' => '/module-assets/index.php?module=pihole&path=assets/pihole_instances.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole_instances.js') ? filemtime(__DIR__ . '/assets/pihole_instances.js') : '0'))],
['src' => '/module-assets/index.php?module=pihole&path=assets/pihole-native.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole-native.js') ? filemtime(__DIR__ . '/assets/pihole-native.js') : '0'))],
],
],
],
];

View File

@@ -0,0 +1,15 @@
# Pi-hole
Importiertes Nexus-Modul fuer Pi-hole Monitoring, Listenpflege und Instanzsteuerung.
## Desktop-Anbindung
- Desktop-App unter `/apps/pihole/index.php`
- API-Endpunkt unter `/api/pihole/index.php`
- native Modul-Einbettung mit globaler Desktop-Sidebar
## Settings
- bestehende Nexus-Settings werden ueber `nexus_module_settings` gelesen
- neue Overrides werden in `data/module-settings/pihole.json` gespeichert
- Instanzen werden weiterhin im Modul-Setting `instances_json` verwaltet

View File

@@ -0,0 +1,66 @@
{
"title": "Pi-hole",
"version": "0.1.0",
"description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.",
"enabled_by_default": true,
"setup": {
"fields": [
{
"name": "api_path",
"label": "API Pfad",
"type": "text",
"help": "Standard ist /admin/api.php fuer v5-Instanzen. v6 wird automatisch erkannt.",
"required": false
},
{
"name": "verify_tls",
"label": "TLS Zertifikate pruefen",
"type": "checkbox",
"help": "Bei internen Testsystemen nur deaktivieren, wenn self-signed Zertifikate genutzt werden.",
"required": false
},
{
"name": "api_timeout_sec",
"label": "API Timeout Standard (Sekunden)",
"type": "number",
"help": "Timeout fuer normale Pi-hole API-Abfragen wie Dashboard und Queries. Standard: 8 Sekunden.",
"required": false
},
{
"name": "action_timeout_sec",
"label": "API Timeout Aktionen (Sekunden)",
"type": "number",
"help": "Timeout fuer laengere Aktionen wie Listen- oder Pi-hole-Updates. Standard: 120 Sekunden.",
"required": false
},
{
"name": "dashboard_refresh_sec",
"label": "Refresh Dashboard (Sekunden)",
"type": "number",
"help": "Automatische Aktualisierung fuer das Dashboard. Standard: 1 Sekunde. 0 deaktiviert den Auto-Refresh.",
"required": false
},
{
"name": "lists_refresh_sec",
"label": "Refresh Listen (Sekunden)",
"type": "number",
"help": "Automatische Aktualisierung fuer die Listen-Seite. Standard: 5 Sekunden. 0 deaktiviert den Auto-Refresh.",
"required": false
},
{
"name": "queries_refresh_sec",
"label": "Refresh Queries (Sekunden)",
"type": "number",
"help": "Automatische Aktualisierung fuer die Queries-Seite. Standard: 5 Sekunden. 0 deaktiviert den Auto-Refresh.",
"required": false
},
{
"name": "lists_primary_only",
"label": "Listen nur auf Primaer-Instanz bearbeiten",
"type": "checkbox",
"help": "Erzwingt Listen- und Domain-Aenderungen nur auf der Primaer-Instanz.",
"required": false
}
]
}
}

View File

@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
require_once dirname(__DIR__) . '/bootstrap.php';
use ModulesCore\ModuleHttp;
session_start();
$projectRoot = dirname(__DIR__, 3);
ModuleHttp::requireDesktopAccess($projectRoot);
$isPartial = !empty($_GET['partial']);
$view = trim((string) ($_GET['view'] ?? 'dashboard'));
$allowedViews = ['dashboard', 'lists', 'queries', 'instances', 'setup'];
if (!in_array($view, $allowedViews, true)) {
$view = 'dashboard';
}
$viewMeta = [
'dashboard' => [
'label' => 'Dashboard',
'title' => 'Pi-hole Dashboard',
'description' => 'Status, Blockings, Usage und direkte Steuerung fuer alle angebundenen Instanzen.',
'nav_description' => 'Live-Status, Sperren und aggregierte DNS-Zahlen.',
],
'lists' => [
'label' => 'Listen',
'title' => 'Listen & Domains',
'description' => 'Gravity-Updates, Top-Domains und neue Listen-/Domain-Eintraege.',
'nav_description' => 'Blocklisten, Whitelist und Adlist-Pflege.',
],
'queries' => [
'label' => 'Queries',
'title' => 'Zugriffe & Blockings',
'description' => 'Aktuelle Blockings und Top-Clients ueber alle Instanzen.',
'nav_description' => 'Abfragen, blockierte Domains und Client-Nutzung.',
],
'instances' => [
'label' => 'Instanzen',
'title' => 'Pi-hole Instanzen',
'description' => 'Hosts, Zugangsdaten und Primaer-Instanz administrieren.',
'nav_description' => 'Instanzen anlegen, testen, bearbeiten und loeschen.',
],
'setup' => [
'label' => 'Setup',
'title' => 'Setup',
'description' => 'Timeouts, API-Pfad und globale Pi-hole-Moduloptionen verwalten.',
'nav_description' => 'Standard-Timeouts, TLS und Refresh-Regeln.',
],
];
$assetVersion = static function (string $relativePath): string {
$fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
return $mtime === false ? '0' : (string) $mtime;
};
$renderSetup = static function (): void {
$moduleName = 'pihole';
$manifestPath = dirname(__DIR__) . '/module.json';
$manifestRaw = is_file($manifestPath) ? file_get_contents($manifestPath) : false;
$manifest = is_string($manifestRaw) && trim($manifestRaw) !== '' ? json_decode($manifestRaw, true) : [];
$fields = is_array($manifest['setup']['fields'] ?? null) ? $manifest['setup']['fields'] : [];
$settings = modules()->settings($moduleName);
$notice = null;
$error = null;
$getValue = static function (array $source, string $path): mixed {
$value = $source;
foreach (explode('.', $path) as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return null;
}
$value = $value[$segment];
}
return $value;
};
$setValue = static function (array &$target, string $path, mixed $value): void {
$segments = explode('.', $path);
$cursor = &$target;
foreach ($segments as $index => $segment) {
if ($index === count($segments) - 1) {
$cursor[$segment] = $value;
return;
}
if (!isset($cursor[$segment]) || !is_array($cursor[$segment])) {
$cursor[$segment] = [];
}
$cursor = &$cursor[$segment];
}
};
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'save_setup') {
try {
$nextSettings = $settings;
foreach ($fields as $field) {
if (!is_array($field)) {
continue;
}
$name = trim((string) ($field['name'] ?? ''));
$type = trim((string) ($field['type'] ?? 'text'));
if ($name === '') {
continue;
}
$raw = $_POST['settings'][$name] ?? null;
$value = match ($type) {
'checkbox' => $raw !== null,
'number' => ($raw === null || $raw === '') ? null : (is_numeric((string) $raw) ? 0 + $raw : null),
default => is_string($raw) ? trim($raw) : '',
};
$setValue($nextSettings, $name, $value);
}
modules()->saveSettings($moduleName, $nextSettings);
$settings = modules()->settings($moduleName);
$notice = 'Setup gespeichert.';
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
?>
<div class="pihole-content">
<?php if ($error): ?>
<section class="window-app-card"><div class="pihole-message pihole-message--error"><?= e($error) ?></div></section>
<?php elseif ($notice): ?>
<section class="window-app-card"><div class="pihole-message pihole-message--success"><?= e($notice) ?></div></section>
<?php endif; ?>
<section class="window-app-card">
<div class="pihole-section-header">
<div>
<h3 class="pihole-section-title">Modul-Setup</h3>
<p class="window-app-copy">Bestehende Nexus-Einstellungen werden uebernommen. Aenderungen werden lokal als Override gespeichert.</p>
</div>
</div>
<form method="post" class="pihole-setup-form">
<input type="hidden" name="action" value="save_setup">
<div class="pihole-setup-grid">
<?php foreach ($fields as $field): ?>
<?php
if (!is_array($field)) {
continue;
}
$name = trim((string) ($field['name'] ?? ''));
$label = trim((string) ($field['label'] ?? $name));
$type = trim((string) ($field['type'] ?? 'text'));
$help = trim((string) ($field['help'] ?? ''));
$value = $getValue($settings, $name);
?>
<label class="pihole-field">
<span><?= e($label) ?></span>
<?php if ($type === 'checkbox'): ?>
<input type="checkbox" name="settings[<?= e($name) ?>]" value="1" <?= !empty($value) ? 'checked' : '' ?>>
<?php elseif ($type === 'number'): ?>
<input type="number" step="any" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
<?php else: ?>
<input type="<?= $type === 'password' ? 'password' : 'text' ?>" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
<?php endif; ?>
<?php if ($help !== ''): ?>
<small class="window-app-meta"><?= e($help) ?></small>
<?php endif; ?>
</label>
<?php endforeach; ?>
</div>
<div class="pihole-actions">
<button class="window-app-nav-button pihole-primary-action" type="submit">Setup speichern</button>
</div>
</form>
</section>
</div>
<?php
};
$renderContent = static function () use ($view, $renderSetup, $viewMeta): void {
$currentViewMeta = $viewMeta[$view] ?? $viewMeta['dashboard'];
$navItems = [
'dashboard' => '/apps/pihole/index.php?view=dashboard',
'lists' => '/apps/pihole/index.php?view=lists',
'queries' => '/apps/pihole/index.php?view=queries',
'instances' => '/apps/pihole/index.php?view=instances',
'setup' => '/apps/pihole/index.php?view=setup',
];
ob_start();
if ($view === 'setup') {
$renderSetup();
} else {
require dirname(__DIR__) . '/partials/' . $view . '.php';
}
$innerContent = (string) ob_get_clean();
?>
<div class="pihole-module-shell window-app-shell">
<div class="window-app-frame pihole-frame">
<aside class="window-app-sidebar pihole-sidebar">
<div class="window-app-brand pihole-brand">
<p class="window-app-kicker">Modul</p>
<h1>Pi-hole</h1>
<p class="window-app-copy">Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen im Desktop-Standardlayout.</p>
</div>
<div class="window-app-nav-list pihole-nav-list">
<?php foreach ($navItems as $navView => $href): ?>
<?php $meta = $viewMeta[$navView] ?? null; ?>
<?php if (!$meta): continue; endif; ?>
<a class="window-app-nav-button pihole-nav-button<?= $view === $navView ? ' is-active' : '' ?>" href="<?= e($href) ?>">
<strong><?= e((string) $meta['label']) ?></strong>
<span class="window-app-meta"><?= e((string) $meta['nav_description']) ?></span>
</a>
<?php endforeach; ?>
</div>
</aside>
<main class="window-app-main pihole-main">
<div class="window-app-panel pihole-panel-shell">
<section class="window-app-hero pihole-hero">
<div>
<p class="window-app-kicker">Pi-hole</p>
<h2 class="window-app-title"><?= e((string) $currentViewMeta['title']) ?></h2>
<p class="window-app-copy"><?= e((string) $currentViewMeta['description']) ?></p>
</div>
<div class="window-app-pill-row pihole-pill-row">
<span class="window-app-pill">Desktop-App</span>
<span class="window-app-pill">Netzwerk-Tools</span>
<span class="window-app-pill">View: <?= e((string) $currentViewMeta['label']) ?></span>
</div>
</section>
<?= $innerContent ?>
</div>
</main>
</div>
</div>
<?php
};
ob_start();
$renderContent();
$pageContent = (string) ob_get_clean();
if ($isPartial) {
echo $pageContent;
return;
}
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pi-hole</title>
<link rel="stylesheet" href="/assets/desktop/desktop.css">
<link rel="stylesheet" href="/module-assets/index.php?module=pihole&amp;path=assets/pihole.css&amp;v=<?= htmlspecialchars($assetVersion('assets/pihole.css'), ENT_QUOTES) ?>">
</head>
<body>
<?= $pageContent ?>
<script src="/module-assets/index.php?module=pihole&amp;path=assets/pihole.js&amp;v=<?= htmlspecialchars($assetVersion('assets/pihole.js'), ENT_QUOTES) ?>"></script>
<script src="/module-assets/index.php?module=pihole&amp;path=assets/pihole_instances.js&amp;v=<?= htmlspecialchars($assetVersion('assets/pihole_instances.js'), ENT_QUOTES) ?>"></script>
</body>
</html>

View File

@@ -0,0 +1,134 @@
<?php
$settings = modules()->settings('pihole');
$instances = module_fn('pihole', 'instances');
$hasConfig = !empty($instances);
$refreshSeconds = (int)($settings['dashboard_refresh_sec'] ?? 1);
if ($refreshSeconds < 0) {
$refreshSeconds = 1;
}
?>
<div class="pihole-content pihole-page" data-pihole-page="dashboard" data-refresh-seconds="<?= e((string)$refreshSeconds) ?>">
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Hosts</strong>
<a class="window-app-nav-button pihole-link-button" href="/apps/pihole/index.php?view=instances">Instanzen verwalten</a>
</div>
<?php if (!$instances): ?>
<div class="muted" style="margin-top:.75rem;">Keine Pi-hole Instanzen vorhanden. Bitte zuerst hinzufuegen.</div>
<div style="margin-top:.75rem;"><a class="window-app-nav-button pihole-primary-action" href="/apps/pihole/index.php?view=instances">+ Neue Instanz</a></div>
<?php else: ?>
<div class="pihole-list" style="margin-top:1rem;">
<?php foreach ($instances as $instance): ?>
<div class="pihole-list-row">
<div>
<strong><?= e((string)($instance['name'] ?? $instance['id'] ?? '')) ?></strong>
<div class="muted"><?= e((string)($instance['url'] ?? '')) ?></div>
</div>
<?php if (!empty($instance['is_primary'])): ?>
<span class="pihole-status">Primaer</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<?php if (!$hasConfig): ?>
<?php return; ?>
<?php else: ?>
<div class="pihole-grid">
<div class="window-app-card pihole-stat">
<div class="muted">DNS Queries (heute)</div>
<div class="pihole-stat-value" data-summary-dns></div>
</div>
<div class="window-app-card pihole-stat">
<div class="muted">Ads geblockt</div>
<div class="pihole-stat-value" data-summary-blocked></div>
<div class="pihole-stat-sub" data-summary-percent></div>
</div>
<div class="window-app-card pihole-stat">
<div class="muted">Unique Clients</div>
<div class="pihole-stat-value" data-summary-clients></div>
</div>
<div class="window-app-card pihole-stat">
<div class="muted">Status</div>
<div class="pihole-stat-value" data-summary-status></div>
</div>
</div>
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Blocker steuern (alle Instanzen)</strong>
<span class="muted" data-summary-last-refresh>Letztes Update: </span>
</div>
<div class="pihole-actions" data-global-actions>
<button class="cta-button" data-action="enable" data-instance="all">Aktivieren</button>
<button class="nav-link" data-action="disable" data-minutes="5" data-instance="all">5 Min</button>
<button class="nav-link" data-action="disable" data-minutes="10" data-instance="all">10 Min</button>
<button class="nav-link" data-action="disable" data-minutes="20" data-instance="all">20 Min</button>
<button class="nav-link" data-action="disable" data-minutes="30" data-instance="all">30 Min</button>
<button class="nav-link" data-action="disable" data-minutes="60" data-instance="all">60 Min</button>
<div class="pihole-inline">
<input type="number" min="1" max="1440" placeholder="Minuten" data-custom-minutes="all">
<button class="nav-link" data-action="disable-custom" data-instance="all">Custom</button>
</div>
</div>
</section>
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Instanzen</strong>
<span class="muted">Einzeln steuerbar &amp; getrennte Updates</span>
</div>
<div class="pihole-instance-grid" data-instance-cards></div>
</section>
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Usage (Aggregiert)</strong>
<span class="muted">Query-Typen und Weiterleitungen</span>
</div>
<div class="pihole-split" style="margin-top:1rem;">
<div>
<div class="muted">Query-Typen</div>
<div class="pihole-list" data-query-types></div>
</div>
<div>
<div class="muted">Forward Destinations</div>
<div class="pihole-list" data-forward-destinations></div>
</div>
</div>
</section>
<?php endif; ?>
</div>
<template id="pihole-instance-template">
<div class="window-app-card pihole-instance" data-instance="">
<div class="pihole-instance-header">
<div>
<strong data-instance-name></strong>
<div class="muted" data-instance-url></div>
</div>
<div class="pihole-status" data-instance-status></div>
</div>
<div class="pihole-instance-stats">
<div><span class="muted">DNS heute</span><div class="pihole-instance-value" data-instance-dns></div></div>
<div><span class="muted">Ads geblockt</span><div class="pihole-instance-value" data-instance-ads></div></div>
<div><span class="muted">% Blocked</span><div class="pihole-instance-value" data-instance-percent></div></div>
</div>
<div class="pihole-actions" data-instance-actions>
<button class="cta-button" data-action="enable" data-instance="">Aktivieren</button>
<button class="nav-link" data-action="disable" data-minutes="5" data-instance="">5 Min</button>
<button class="nav-link" data-action="disable" data-minutes="10" data-instance="">10 Min</button>
<button class="nav-link" data-action="disable" data-minutes="30" data-instance="">30 Min</button>
<div class="pihole-inline">
<input type="number" min="1" max="1440" placeholder="Minuten" data-custom-minutes="">
<button class="nav-link" data-action="disable-custom" data-instance="">Custom</button>
</div>
<button class="nav-link" data-action="update" data-instance="">Pi-hole Update</button>
</div>
<div class="pihole-update" data-instance-update></div>
<div class="pihole-error" data-instance-errors></div>
</div>
</template>

View File

@@ -0,0 +1,408 @@
<?php
$moduleName = 'pihole';
require_admin();
$settings = modules()->settings($moduleName);
$notice = null;
$error = null;
$testResults = [];
$httpRequest = static function (string $method, string $url, array $headers, ?string $body, bool $verify, int $timeout): array {
$raw = '';
$httpCode = 0;
$requestError = '';
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => $verify,
CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$raw = (string)curl_exec($ch);
if ($raw === '' && curl_errno($ch)) {
$requestError = curl_error($ch);
}
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
} else {
$ctx = stream_context_create([
'http' => [
'method' => $method,
'timeout' => $timeout,
'header' => implode("\r\n", $headers),
'content' => $body ?? '',
],
'ssl' => [
'verify_peer' => $verify,
'verify_peer_name' => $verify,
],
]);
$raw = (string)@file_get_contents($url, false, $ctx);
if ($raw === '') {
$requestError = 'HTTP request failed';
} else {
$httpCode = 200;
}
}
if ($requestError !== '') {
return ['ok' => false, 'http_code' => $httpCode, 'error' => $requestError, 'raw' => $raw, 'url' => $url];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return ['ok' => false, 'http_code' => $httpCode, 'error' => 'invalid_json', 'raw' => $raw, 'url' => $url];
}
return ['ok' => true, 'http_code' => $httpCode, 'data' => $decoded, 'url' => $url];
};
$runConnectionTest = static function (array $instance, array $settings) use ($httpRequest): array {
$verifyTls = !isset($settings['verify_tls']) || $settings['verify_tls'] === '1' || $settings['verify_tls'] === 1 || $settings['verify_tls'] === true;
$timeout = (int)($settings['api_timeout_sec'] ?? 8);
if ($timeout <= 0) {
$timeout = 8;
}
$url = rtrim((string)($instance['url'] ?? ''), '/');
$password = trim((string)($instance['password'] ?? ''));
$v6AuthUrl = $url . '/api/auth';
$v6Payload = json_encode(['password' => $password], JSON_UNESCAPED_UNICODE);
$v6Auth = $httpRequest('POST', $v6AuthUrl, ['Accept: application/json', 'Content-Type: application/json'], $v6Payload, $verifyTls, $timeout);
if ($v6Auth['ok']) {
$session = (array)(($v6Auth['data']['session'] ?? []) ?: []);
$sid = trim((string)($session['sid'] ?? ''));
if ($sid !== '') {
$probe = $httpRequest('GET', $url . '/api/stats/summary', ['Accept: application/json', 'X-FTL-SID: ' . $sid], null, $verifyTls, $timeout);
if ($probe['ok']) {
return [
'ok' => true,
'status' => 'ok',
'message' => 'Verbindung OK. API v6 antwortet.',
'version' => 6,
'details' => ['auth' => $v6Auth, 'probe' => $probe],
];
}
return [
'ok' => false,
'status' => 'error',
'message' => 'v6 Auth OK, aber Stats-Endpoint antwortet nicht sauber.',
'version' => 6,
'details' => ['auth' => $v6Auth, 'probe' => $probe],
];
}
}
$legacyUrl = $url . '/admin/api.php?summaryRaw';
if ($password !== '') {
$legacyUrl .= '&auth=' . rawurlencode($password);
}
$v5Probe = $httpRequest('GET', $legacyUrl, ['Accept: application/json'], null, $verifyTls, $timeout);
if ($v5Probe['ok']) {
return [
'ok' => true,
'status' => 'ok',
'message' => 'Verbindung OK. API v5 antwortet.',
'version' => 5,
'details' => ['auth' => $v6Auth, 'probe' => $v5Probe],
];
}
$status = 'error';
$message = 'Unbekannter Fehler.';
$httpCode = (int)($v6Auth['http_code'] ?? $v5Probe['http_code'] ?? 0);
$requestError = (string)($v6Auth['error'] ?? $v5Probe['error'] ?? 'error');
if ($httpCode === 0) {
$status = 'unreachable';
$message = 'Host nicht erreichbar oder kein HTTP-Response.';
} elseif ($httpCode === 401 || $httpCode === 403) {
$status = 'auth';
$message = 'Passwort oder App-Passwort falsch oder nicht berechtigt.';
} elseif ($requestError === 'invalid_json') {
$status = 'invalid';
$message = 'API antwortet nicht mit JSON. URL pruefen.';
} else {
$message = 'API Fehler: ' . $requestError . ' (HTTP ' . $httpCode . ')';
}
return [
'ok' => false,
'status' => $status,
'message' => $message,
'details' => ['auth' => $v6Auth, 'probe' => $v5Probe],
];
};
$loadInstances = function (array $settings): array {
$normalizeSecret = static function (array $row): string {
$password = trim((string)($row['password'] ?? ''));
if ($password !== '') {
return $password;
}
return trim((string)($row['token'] ?? ''));
};
$instances = [];
$rawJson = trim((string)($settings['instances_json'] ?? ''));
if ($rawJson !== '') {
$decoded = json_decode($rawJson, true);
if (is_array($decoded)) {
foreach ($decoded as $row) {
if (!is_array($row)) {
continue;
}
$id = trim((string)($row['id'] ?? ''));
$url = trim((string)($row['url'] ?? ''));
if ($id === '' || $url === '') {
continue;
}
$instances[$id] = [
'id' => $id,
'name' => trim((string)($row['name'] ?? '')) ?: $id,
'url' => $url,
'password' => $normalizeSecret($row),
'is_primary' => !empty($row['is_primary']),
];
}
}
}
if (!$instances) {
foreach (['primary', 'secondary'] as $key) {
$url = trim((string)($settings[$key . '_url'] ?? ''));
if ($url === '') {
continue;
}
$instances[$key] = [
'id' => $key,
'name' => trim((string)($settings[$key . '_name'] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'),
'url' => $url,
'password' => trim((string)($settings[$key . '_token'] ?? '')),
'is_primary' => $key === 'primary',
];
}
}
return $instances;
};
$instances = $loadInstances($settings);
$sanitizeId = function (string $id): string {
$id = preg_replace('/[^a-zA-Z0-9_-]/', '', $id);
return trim((string)$id);
};
$saveInstances = function (array $settings, array $instances): void {
$payload = $settings;
$payload['instances_json'] = json_encode(array_values($instances), JSON_UNESCAPED_UNICODE);
modules()->saveSettings('pihole', $payload);
};
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$deleteId = trim((string)($_POST['delete_id'] ?? ''));
$testId = trim((string)($_POST['test_id'] ?? ''));
$currentId = trim((string)($_POST['current_id'] ?? ''));
$instanceId = trim((string)($_POST['instance_id'] ?? ''));
$name = trim((string)($_POST['name'] ?? ''));
$url = trim((string)($_POST['url'] ?? ''));
$password = trim((string)($_POST['password'] ?? ''));
$isPrimary = isset($_POST['is_primary']);
if ($testId !== '') {
if (isset($instances[$testId])) {
module_debug_push('pihole', [
'label' => 'connection.test.start',
'instance_id' => $testId,
'instance_name' => (string)($instances[$testId]['name'] ?? $testId),
'url' => (string)($instances[$testId]['url'] ?? ''),
]);
$result = $runConnectionTest($instances[$testId], $settings);
$testResults[$testId] = $result;
module_debug_push('pihole', [
'label' => 'connection.test.result',
'instance_id' => $testId,
'instance_name' => (string)($instances[$testId]['name'] ?? $testId),
'result' => $result,
]);
$notice = $result['message'] ?? null;
} else {
$error = 'Test-Instanz nicht gefunden.';
}
} elseif ($deleteId !== '') {
if (isset($instances[$deleteId])) {
unset($instances[$deleteId]);
$notice = 'Instanz geloescht.';
}
} else {
$instanceId = $sanitizeId($instanceId);
if ($instanceId === '' || $url === '') {
$error = 'Bitte ID und URL angeben.';
} elseif ($currentId === '' && isset($instances[$instanceId])) {
$error = 'Die Instanz-ID ist bereits vergeben. Bitte eine eindeutige ID verwenden.';
} elseif ($currentId !== '' && $currentId !== $instanceId && isset($instances[$instanceId])) {
$error = 'Die neue Instanz-ID ist bereits vergeben. Bitte eine eindeutige ID verwenden.';
} else {
$existingPassword = '';
if ($currentId !== '' && isset($instances[$currentId])) {
$existingPassword = (string)($instances[$currentId]['password'] ?? '');
}
$passwordToStore = $password !== '' ? $password : $existingPassword;
if ($currentId !== '' && $currentId !== $instanceId) {
unset($instances[$currentId]);
}
$instances[$instanceId] = [
'id' => $instanceId,
'name' => $name !== '' ? $name : $instanceId,
'url' => $url,
'password' => $passwordToStore,
'is_primary' => $isPrimary,
];
if ($isPrimary) {
foreach ($instances as $id => &$row) {
$row['is_primary'] = ($id === $instanceId);
}
unset($row);
$settings['primary_id'] = $instanceId;
}
$notice = $currentId !== '' ? 'Instanz aktualisiert.' : 'Instanz gespeichert.';
}
}
if (!$error) {
$saveInstances($settings, $instances);
$settings = modules()->settings($moduleName);
$instances = $loadInstances($settings);
}
}
$primaryId = trim((string)($settings['primary_id'] ?? ''));
if ($primaryId === '') {
foreach ($instances as $id => $row) {
if (!empty($row['is_primary'])) {
$primaryId = $id;
break;
}
}
}
?>
<div class="pihole-content">
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<div>
<h3 class="pihole-section-title">Instanzen</h3>
<p class="window-app-copy">Pi-hole Instanzen hinzufuegen, bearbeiten und loeschen.</p>
</div>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button class="window-app-nav-button pihole-primary-action" type="button" data-instance-new>+ Neue Instanz</button>
</div>
</div>
<?php if ($error): ?>
<div class="pihole-message pihole-message--error" style="margin-top:16px;">
<?= e($error) ?>
</div>
<?php elseif ($notice): ?>
<div class="pihole-message pihole-message--success" style="margin-top:16px;">
<?= e($notice) ?>
</div>
<?php endif; ?>
<div class="pihole-instance-grid" style="margin-top:16px;">
<?php if (!$instances): ?>
<div class="window-app-card pihole-card">Keine Instanzen vorhanden.</div>
<?php else: ?>
<?php foreach ($instances as $instance): ?>
<div class="window-app-card pihole-instance-card"
data-instance-id="<?= e((string)$instance['id']) ?>"
data-name="<?= e((string)($instance['name'] ?? '')) ?>"
data-url="<?= e((string)($instance['url'] ?? '')) ?>"
data-primary="<?= !empty($instance['is_primary']) ? '1' : '0' ?>">
<div class="pihole-instance-header">
<div>
<strong><?= e((string)($instance['name'] ?? '')) ?></strong>
<div class="muted">ID: <?= e((string)($instance['id'] ?? '')) ?></div>
<div class="muted">URL: <?= e((string)($instance['url'] ?? '')) ?></div>
</div>
<?php if (!empty($instance['is_primary']) || $instance['id'] === $primaryId): ?>
<span class="pihole-status">Primaer</span>
<?php endif; ?>
</div>
<div class="pihole-card-actions">
<button class="window-app-nav-button pihole-link-button" type="button" data-instance-edit>Bearbeiten</button>
<form method="post">
<input type="hidden" name="test_id" value="<?= e((string)($instance['id'] ?? '')) ?>">
<button class="window-app-nav-button pihole-link-button" type="submit" data-instance-test>Test Verbindung</button>
</form>
<form method="post" onsubmit="return confirm('Instanz wirklich loeschen?')">
<input type="hidden" name="delete_id" value="<?= e((string)($instance['id'] ?? '')) ?>">
<button class="window-app-nav-button pihole-link-button" type="submit">Loeschen</button>
</form>
</div>
<div class="pihole-test-result<?= !empty($testResults[$instance['id']]['status']) ? ' is-' . e((string)$testResults[$instance['id']]['status']) : '' ?>" data-instance-result><?= e((string)($testResults[$instance['id']]['message'] ?? '')) ?></div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</section>
</div>
<div class="modal" data-instance-modal aria-hidden="true">
<div class="modal-card pihole-modal-card" role="dialog" aria-modal="true" aria-labelledby="pihole-instance-modal-title">
<div class="modal-header">
<div>
<strong id="pihole-instance-modal-title" data-instance-modal-title>Neue Instanz</strong>
<div class="muted">Pi-hole Host anlegen oder bestehende Instanz bearbeiten.</div>
</div>
<button class="icon-button" type="button" data-instance-close aria-label="Modal schliessen">×</button>
</div>
<form method="post" class="pihole-instance-form" data-instance-form>
<input type="hidden" name="current_id" value="">
<div class="form-grid pihole-instance-form-grid">
<label class="form-field pihole-form-field-wide">
<span class="muted">ID</span>
<input type="text" name="instance_id" placeholder="z.B. pihole-main" required>
<small class="muted">Die ID muss eindeutig sein und wird nur intern verwendet.</small>
</label>
<label class="form-field">
<span class="muted">Name</span>
<input type="text" name="name" placeholder="z.B. Pi-hole Main" required>
</label>
<label class="form-field">
<span class="muted">URL</span>
<input type="text" name="url" placeholder="http://pi-hole.local" required>
</label>
<label class="form-field pihole-form-field-wide">
<span class="muted">Passwort / App-Passwort</span>
<input type="password" name="password" placeholder="Pi-hole Passwort oder App-Passwort" autocomplete="new-password">
<small class="muted">Beim Bearbeiten leer lassen, um das gespeicherte Passwort unveraendert zu lassen.</small>
</label>
<label class="pihole-checkbox-field">
<input type="checkbox" name="is_primary" value="1">
<span>Als Primaer verwenden</span>
</label>
</div>
<div class="pihole-modal-actions">
<button class="window-app-nav-button pihole-primary-action" type="submit" data-instance-submit>Speichern</button>
<button class="window-app-nav-button pihole-link-button" type="button" data-instance-cancel>Abbrechen</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,88 @@
<?php
$settings = modules()->settings('pihole');
$instances = module_fn('pihole', 'instances');
$hasConfig = !empty($instances);
$refreshSeconds = (int)($settings['lists_refresh_sec'] ?? 5);
if ($refreshSeconds < 0) {
$refreshSeconds = 5;
}
?>
<div class="pihole-content pihole-page" data-pihole-page="lists" data-refresh-seconds="<?= e((string)$refreshSeconds) ?>">
<?php if (!$hasConfig): ?>
<div class="window-app-card pihole-card">
<strong>Keine Instanzen konfiguriert</strong>
<div class="muted" style="margin-top:.35rem;">Bitte zuerst eine Pi-hole Instanz hinzufuegen.</div>
<div style="margin-top:.75rem;"><a class="window-app-nav-button pihole-link-button" href="/apps/pihole/index.php?view=instances">Instanzen verwalten</a></div>
</div>
<?php else: ?>
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Listen-Updates</strong>
<span class="muted">Gravity / Blocklisten fuer alle oder einzelne Instanzen neu laden</span>
</div>
<div class="pihole-actions" data-list-actions>
<button class="cta-button" data-action="gravity" data-instance="all">Alle Instanzen aktualisieren</button>
<?php foreach ($instances as $instance): ?>
<button class="nav-link" data-action="gravity" data-instance="<?= e((string)($instance['id'] ?? '')) ?>">
<?= e((string)($instance['name'] ?? $instance['id'] ?? 'Instanz')) ?>
</button>
<?php endforeach; ?>
</div>
<div class="pihole-update" data-list-update-status></div>
</section>
<div class="pihole-split">
<div class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Top geblockte Domains (Aggregiert)</strong>
<span class="muted">Letzte Statistiken</span>
</div>
<div class="pihole-list" data-top-ads></div>
</div>
<div class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Top erlaubte Domains (Aggregiert)</strong>
<span class="muted">Letzte Statistiken</span>
</div>
<div class="pihole-list" data-top-queries></div>
</div>
</div>
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Domainlisten erweitern</strong>
<span class="muted">Eintraege werden auf der Primaer-Instanz gesetzt</span>
</div>
<form class="pihole-form" data-domain-form>
<label>
<span class="muted">Typ</span>
<select name="type">
<option value="block">Blacklist (Blocken)</option>
<option value="allow">Whitelist (Erlauben)</option>
</select>
</label>
<label>
<span class="muted">Domain</span>
<input type="text" name="domain" placeholder="example.com" required>
</label>
<button class="cta-button" type="submit">Hinzufuegen</button>
</form>
<div class="pihole-update" data-domain-status></div>
</section>
<section class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Adlist-URL hinzufuegen</strong>
<span class="muted">Optional: unterstuetzt nur wenn die API den Endpunkt anbietet.</span>
</div>
<form class="pihole-form" data-adlist-form>
<label>
<span class="muted">Adlist URL</span>
<input type="text" name="url" placeholder="https://example.com/list.txt" required>
</label>
<button class="nav-link" type="submit">Adlist hinzufuegen</button>
</form>
<div class="pihole-update" data-adlist-status></div>
</section>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,35 @@
<?php
$settings = modules()->settings('pihole');
$instances = module_fn('pihole', 'instances');
$hasConfig = !empty($instances);
$refreshSeconds = (int)($settings['queries_refresh_sec'] ?? 5);
if ($refreshSeconds < 0) {
$refreshSeconds = 5;
}
?>
<div class="pihole-content pihole-page" data-pihole-page="queries" data-refresh-seconds="<?= e((string)$refreshSeconds) ?>">
<?php if (!$hasConfig): ?>
<div class="window-app-card pihole-card">
<strong>Keine Instanzen konfiguriert</strong>
<div class="muted" style="margin-top:.35rem;">Bitte zuerst eine Pi-hole Instanz hinzufuegen.</div>
<div style="margin-top:.75rem;"><a class="window-app-nav-button pihole-link-button" href="/apps/pihole/index.php?view=instances">Instanzen verwalten</a></div>
</div>
<?php else: ?>
<div class="pihole-split">
<div class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Aktuelle Blockings</strong>
<span class="muted">Letzte geblockte Domains</span>
</div>
<div class="pihole-blocked" data-recent-blocked></div>
</div>
<div class="window-app-card pihole-card">
<div class="pihole-section-header">
<strong>Top Clients (Aggregiert)</strong>
<span class="muted">Anfragen nach Client</span>
</div>
<div class="pihole-list" data-top-clients></div>
</div>
</div>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require dirname(__DIR__, 3) . '/modules/pihole/api/index.php';

View File

@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require dirname(__DIR__, 3) . '/modules/pihole/pages/index.php';

View File

@@ -478,6 +478,19 @@ namespace {
}
}
if (!function_exists('require_admin')) {
function require_admin(): void
{
if (auth_is_admin()) {
return;
}
http_response_code(403);
echo 'Forbidden';
exit;
}
}
if (!function_exists('module_design')) {
function module_design(string $module): array
{