Modul pihole
This commit is contained in:
173
modules/pihole/assets/pihole.css
Normal file
173
modules/pihole/assets/pihole.css
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
.pihole-page .card { background: var(--panel); }
|
||||||
|
|
||||||
|
.pihole-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-stat {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-stat-value {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-stat-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-inline input {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-instance-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-instance {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-instance-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-instance-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-instance-value {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-status {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(0, 179, 164, 0.15);
|
||||||
|
color: #0a6b63;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-status.is-disabled {
|
||||||
|
background: rgba(255, 90, 61, 0.15);
|
||||||
|
color: #a83a28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-status.is-partial {
|
||||||
|
background: rgba(255, 166, 0, 0.18);
|
||||||
|
color: #8a5a00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-list-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-list-row strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-update {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-blocked {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-blocked-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 90, 61, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.pihole-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-inline {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pihole-inline input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
251
modules/pihole/assets/pihole.js
Normal file
251
modules/pihole/assets/pihole.js
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
(() => {
|
||||||
|
const page = document.querySelector('[data-pihole-page]');
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('de-DE');
|
||||||
|
const fmtDate = (ts) => {
|
||||||
|
if (!ts) return '–';
|
||||||
|
const d = new Date(ts * 1000);
|
||||||
|
return d.toLocaleString('de-DE');
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiCall = async (action, payload = {}) => {
|
||||||
|
const res = await fetch(`/module/pihole/api?action=${encodeURIComponent(action)}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setText = (el, value) => {
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel = (status) => {
|
||||||
|
if (status === 'enabled') return 'Aktiv';
|
||||||
|
if (status === 'disabled') return 'Deaktiviert';
|
||||||
|
if (status === 'partial') return 'Teilweise';
|
||||||
|
return 'Unbekannt';
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatusBadge = (el, status) => {
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = statusLabel(status);
|
||||||
|
el.classList.remove('is-disabled', 'is-partial');
|
||||||
|
if (status === 'disabled') el.classList.add('is-disabled');
|
||||||
|
if (status === 'partial') el.classList.add('is-partial');
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapToList = (map, limit = 10) => {
|
||||||
|
if (!map || typeof map !== 'object') return [];
|
||||||
|
const entries = Object.entries(map)
|
||||||
|
.filter(([, value]) => typeof value === 'number' || typeof value === 'string')
|
||||||
|
.map(([key, value]) => [key, Number(value)])
|
||||||
|
.sort((a, b) => b[1] - a[1]);
|
||||||
|
return entries.slice(0, limit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderList = (container, map, emptyText) => {
|
||||||
|
if (!container) return;
|
||||||
|
const rows = mapToList(map, 10);
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!rows.length) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'muted';
|
||||||
|
div.textContent = emptyText || 'Keine Daten';
|
||||||
|
container.appendChild(div);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows.forEach(([label, value]) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'pihole-list-row';
|
||||||
|
row.innerHTML = `<strong>${label}</strong><span>${fmt.format(value)}</span>`;
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBlocked = (container, list) => {
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!Array.isArray(list) || !list.length) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'muted';
|
||||||
|
div.textContent = 'Keine Daten';
|
||||||
|
container.appendChild(div);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.slice(0, 20).forEach((entry) => {
|
||||||
|
const domain = entry.domain || entry;
|
||||||
|
const instance = entry.instance || '';
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'pihole-blocked-row';
|
||||||
|
row.innerHTML = `<span>${domain}</span><span class="muted">${instance}</span>`;
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderInstances = (instances) => {
|
||||||
|
const holder = document.querySelector('[data-instance-cards]');
|
||||||
|
const tpl = document.querySelector('#pihole-instance-template');
|
||||||
|
if (!holder || !tpl) return;
|
||||||
|
holder.innerHTML = '';
|
||||||
|
|
||||||
|
Object.values(instances).forEach((entry) => {
|
||||||
|
const node = tpl.content.cloneNode(true);
|
||||||
|
const root = node.querySelector('[data-instance]');
|
||||||
|
if (!root) return;
|
||||||
|
root.dataset.instance = entry.meta.id;
|
||||||
|
|
||||||
|
const summary = entry.summary || {};
|
||||||
|
setText(root.querySelector('[data-instance-name]'), entry.meta.name || entry.meta.id);
|
||||||
|
setText(root.querySelector('[data-instance-url]'), entry.meta.url || '');
|
||||||
|
setText(root.querySelector('[data-instance-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
|
||||||
|
setText(root.querySelector('[data-instance-ads]'), fmt.format(Number(summary.ads_blocked_today || 0)));
|
||||||
|
const percent = summary.ads_percentage_today || summary.ads_percentage || 0;
|
||||||
|
setText(root.querySelector('[data-instance-percent]'), `${Number(percent).toFixed(2)}%`);
|
||||||
|
setStatusBadge(root.querySelector('[data-instance-status]'), summary.status || 'unknown');
|
||||||
|
|
||||||
|
const actions = root.querySelectorAll('[data-instance]');
|
||||||
|
actions.forEach((btn) => {
|
||||||
|
if (btn.dataset.instance === '') {
|
||||||
|
btn.dataset.instance = entry.meta.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const customInput = root.querySelector('[data-custom-minutes]');
|
||||||
|
if (customInput) {
|
||||||
|
customInput.dataset.customMinutes = entry.meta.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateEl = root.querySelector('[data-instance-update]');
|
||||||
|
if (entry.updates && entry.updates.available) {
|
||||||
|
updateEl.textContent = 'Updates verfuegbar';
|
||||||
|
} else {
|
||||||
|
updateEl.textContent = 'Keine Updates erkannt';
|
||||||
|
}
|
||||||
|
|
||||||
|
holder.appendChild(node);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindActionButtons = () => {
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
const instance = btn.dataset.instance || 'all';
|
||||||
|
const minutes = btn.dataset.minutes;
|
||||||
|
const scope = btn.closest('.pihole-instance') || document;
|
||||||
|
const customInput = scope.querySelector(`[data-custom-minutes="${instance}"]`);
|
||||||
|
let payload = { instance };
|
||||||
|
|
||||||
|
if (action === 'disable') {
|
||||||
|
payload.minutes = Number(minutes || 0);
|
||||||
|
}
|
||||||
|
if (action === 'disable-custom') {
|
||||||
|
payload.minutes = Number(customInput?.value || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'enable') {
|
||||||
|
await apiCall('enable', payload);
|
||||||
|
} else if (action === 'disable' || action === 'disable-custom') {
|
||||||
|
if (!payload.minutes || payload.minutes <= 0) {
|
||||||
|
alert('Bitte Minuten angeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await apiCall('disable', payload);
|
||||||
|
} else if (action === 'gravity') {
|
||||||
|
await apiCall('gravity', payload);
|
||||||
|
const status = document.querySelector('[data-list-update-status]');
|
||||||
|
if (status) status.textContent = 'Listen-Update gestartet.';
|
||||||
|
} else if (action === 'update') {
|
||||||
|
await apiCall('update', payload);
|
||||||
|
}
|
||||||
|
await loadDashboard();
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Aktion fehlgeschlagen: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindForms = () => {
|
||||||
|
const domainForm = document.querySelector('[data-domain-form]');
|
||||||
|
if (domainForm) {
|
||||||
|
domainForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(domainForm);
|
||||||
|
const type = String(formData.get('type') || 'block');
|
||||||
|
const domain = String(formData.get('domain') || '').trim();
|
||||||
|
const status = document.querySelector('[data-domain-status]');
|
||||||
|
if (!domain) return;
|
||||||
|
try {
|
||||||
|
await apiCall('domain_add', { type, domain, instance: 'primary' });
|
||||||
|
if (status) status.textContent = `Domain hinzugefuegt: ${domain}`;
|
||||||
|
domainForm.reset();
|
||||||
|
} catch (err) {
|
||||||
|
if (status) status.textContent = `Fehler: ${err.message}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const adlistForm = document.querySelector('[data-adlist-form]');
|
||||||
|
if (adlistForm) {
|
||||||
|
adlistForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(adlistForm);
|
||||||
|
const url = String(formData.get('url') || '').trim();
|
||||||
|
const status = document.querySelector('[data-adlist-status]');
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
await apiCall('adlist_add', { url, instance: 'primary' });
|
||||||
|
if (status) status.textContent = `Adlist hinzugefuegt: ${url}`;
|
||||||
|
adlistForm.reset();
|
||||||
|
} catch (err) {
|
||||||
|
if (status) status.textContent = `Fehler: ${err.message}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDashboard = async () => {
|
||||||
|
try {
|
||||||
|
const data = await apiCall('dashboard');
|
||||||
|
if (!data.ok) throw new Error(data.error || 'API error');
|
||||||
|
|
||||||
|
const summary = data.aggregate?.summary || {};
|
||||||
|
setText(document.querySelector('[data-summary-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
|
||||||
|
setText(document.querySelector('[data-summary-blocked]'), fmt.format(Number(summary.ads_blocked_today || 0)));
|
||||||
|
setText(document.querySelector('[data-summary-percent]'), `${Number(summary.ads_percentage_today || 0).toFixed(2)}%`);
|
||||||
|
setText(document.querySelector('[data-summary-clients]'), fmt.format(Number(summary.unique_clients || 0)));
|
||||||
|
setStatusBadge(document.querySelector('[data-summary-status]'), summary.status || 'unknown');
|
||||||
|
setText(document.querySelector('[data-summary-last-refresh]'), `Letztes Update: ${fmtDate(data.ts)}`);
|
||||||
|
|
||||||
|
renderInstances(data.instances || {});
|
||||||
|
renderList(document.querySelector('[data-query-types]'), data.aggregate?.query_types, 'Keine Daten');
|
||||||
|
renderList(document.querySelector('[data-forward-destinations]'), data.aggregate?.forward_destinations, 'Keine Daten');
|
||||||
|
renderList(document.querySelector('[data-top-ads]'), data.aggregate?.top_ads, 'Keine Daten');
|
||||||
|
renderList(document.querySelector('[data-top-queries]'), data.aggregate?.top_queries, 'Keine Daten');
|
||||||
|
renderList(document.querySelector('[data-top-clients]'), data.aggregate?.query_sources, 'Keine Daten');
|
||||||
|
renderBlocked(document.querySelector('[data-recent-blocked]'), data.aggregate?.recent_blocked);
|
||||||
|
} catch (err) {
|
||||||
|
const message = document.createElement('div');
|
||||||
|
message.className = 'card';
|
||||||
|
message.style.marginTop = '1rem';
|
||||||
|
message.textContent = `Fehler beim Laden der Pi-hole Daten: ${err.message}`;
|
||||||
|
page.appendChild(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bindActionButtons();
|
||||||
|
bindForms();
|
||||||
|
loadDashboard();
|
||||||
|
})();
|
||||||
55
modules/pihole/bootstrap.php
Normal file
55
modules/pihole/bootstrap.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
$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, '/'),
|
||||||
|
'token' => trim((string)($settings[$tokenKey] ?? '')),
|
||||||
|
'api_path' => $apiPath,
|
||||||
|
'timeout' => $timeout,
|
||||||
|
'verify_tls' => $verifyTls,
|
||||||
|
'is_primary' => $key === 'primary',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instances;
|
||||||
|
});
|
||||||
|
|
||||||
|
$mm->registerFunction($moduleName, 'lists_primary_only', function () use ($moduleName): bool {
|
||||||
|
$settings = modules()->settings($moduleName);
|
||||||
|
return !empty($settings['lists_primary_only']);
|
||||||
|
});
|
||||||
36
modules/pihole/module.json
Normal file
36
modules/pihole/module.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"title": "Pi-hole",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Pi-hole Monitoring, Listen und Steuerung fuer zwei Instanzen.",
|
||||||
|
"menu": [
|
||||||
|
{ "label": "Dashboard", "href": "/module/pihole" },
|
||||||
|
{ "label": "Listen", "href": "/module/pihole/lists" },
|
||||||
|
{ "label": "Zugriffe", "href": "/module/pihole/queries" },
|
||||||
|
{ "label": "Setup", "href": "/modules/setup/pihole" }
|
||||||
|
],
|
||||||
|
"sidebar": {
|
||||||
|
"enabled": true,
|
||||||
|
"collapsible": true,
|
||||||
|
"default": "collapsed",
|
||||||
|
"items": [
|
||||||
|
{ "label": "Dashboard", "href": "/module/pihole" },
|
||||||
|
{ "label": "Listen", "href": "/module/pihole/lists" },
|
||||||
|
{ "label": "Zugriffe", "href": "/module/pihole/queries" },
|
||||||
|
{ "label": "Setup", "href": "/modules/setup/pihole" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"setup": {
|
||||||
|
"fields": [
|
||||||
|
{ "name": "primary_name", "label": "Primaer-Name", "type": "text", "required": false, "help": "Anzeige-Name (z.B. Pi-hole Main)" },
|
||||||
|
{ "name": "primary_url", "label": "Primaer-URL", "type": "text", "required": true, "help": "Basis-URL der Instanz, z.B. http://pi-hole.local" },
|
||||||
|
{ "name": "primary_token", "label": "Primaer-API Token", "type": "password", "required": false, "help": "Token aus dem Pi-hole Admin (API Token)." },
|
||||||
|
{ "name": "secondary_name", "label": "Sekundaer-Name", "type": "text", "required": false, "help": "Anzeige-Name (z.B. Pi-hole Backup)" },
|
||||||
|
{ "name": "secondary_url", "label": "Sekundaer-URL", "type": "text", "required": false, "help": "Basis-URL der zweiten Instanz" },
|
||||||
|
{ "name": "secondary_token", "label": "Sekundaer-API Token", "type": "password", "required": false },
|
||||||
|
{ "name": "api_path", "label": "API Pfad", "type": "text", "required": false, "help": "Standard: /admin/api.php" },
|
||||||
|
{ "name": "api_timeout_sec", "label": "API Timeout (Sek.)", "type": "number", "required": false, "help": "Standard 8" },
|
||||||
|
{ "name": "verify_tls", "label": "TLS Verifikation aktiv", "type": "checkbox", "required": false, "help": "Deaktivieren nur wenn das Zertifikat unsicher ist." },
|
||||||
|
{ "name": "lists_primary_only", "label": "Listen nur auf Primaer", "type": "checkbox", "required": false, "help": "Listen-Updates und neue Listen nur auf der Primaer-Instanz ausfuehren." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
463
modules/pihole/pages/api.php
Normal file
463
modules/pihole/pages/api.php
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
<?php
|
||||||
|
require_auth();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$action = (string)($_GET['action'] ?? '');
|
||||||
|
$instances = module_fn('pihole', 'instances');
|
||||||
|
$listsPrimaryOnly = module_fn('pihole', 'lists_primary_only');
|
||||||
|
|
||||||
|
$body = file_get_contents('php://input');
|
||||||
|
$payload = [];
|
||||||
|
if ($body !== false && $body !== '') {
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$payload = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$respond = function (array $data, int $status = 200): void {
|
||||||
|
http_response_code($status);
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
};
|
||||||
|
|
||||||
|
$normalizeApiPath = function (string $baseUrl, string $apiPath): string {
|
||||||
|
$base = rtrim($baseUrl, '/');
|
||||||
|
$path = $apiPath;
|
||||||
|
if ($path === '') {
|
||||||
|
$path = '/admin/api.php';
|
||||||
|
}
|
||||||
|
if ($path[0] !== '/') {
|
||||||
|
$path = '/' . $path;
|
||||||
|
}
|
||||||
|
return $base . $path;
|
||||||
|
};
|
||||||
|
|
||||||
|
$apiRequest = function (array $instance, array $params) use ($normalizeApiPath): array {
|
||||||
|
if (!empty($instance['token']) && !isset($params['auth'])) {
|
||||||
|
$params['auth'] = $instance['token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $normalizeApiPath((string)$instance['url'], (string)$instance['api_path']);
|
||||||
|
$qs = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
|
||||||
|
$full = $qs !== '' ? $url . '?' . $qs : $url;
|
||||||
|
|
||||||
|
$timeout = (int)($instance['timeout'] ?? 8);
|
||||||
|
if ($timeout <= 0) {
|
||||||
|
$timeout = 8;
|
||||||
|
}
|
||||||
|
$verify = !empty($instance['verify_tls']);
|
||||||
|
|
||||||
|
$raw = '';
|
||||||
|
$httpCode = 0;
|
||||||
|
$error = '';
|
||||||
|
|
||||||
|
if (function_exists('curl_init')) {
|
||||||
|
$ch = curl_init($full);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => $timeout,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => $timeout,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => $verify,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0,
|
||||||
|
]);
|
||||||
|
$raw = (string)curl_exec($ch);
|
||||||
|
if ($raw === '' && curl_errno($ch)) {
|
||||||
|
$error = curl_error($ch);
|
||||||
|
}
|
||||||
|
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
} else {
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'GET',
|
||||||
|
'timeout' => $timeout,
|
||||||
|
],
|
||||||
|
'ssl' => [
|
||||||
|
'verify_peer' => $verify,
|
||||||
|
'verify_peer_name' => $verify,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$raw = (string)@file_get_contents($full, false, $ctx);
|
||||||
|
$httpCode = 200;
|
||||||
|
if ($raw === '') {
|
||||||
|
$error = 'HTTP request failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($error !== '') {
|
||||||
|
return ['ok' => false, 'error' => $error, 'http_code' => $httpCode, 'url' => $full];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return ['ok' => false, 'error' => 'invalid_json', 'http_code' => $httpCode, 'raw' => $raw, 'url' => $full];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true, 'data' => $data, 'http_code' => $httpCode, 'url' => $full];
|
||||||
|
};
|
||||||
|
|
||||||
|
$pickInstances = function (string $target) use ($instances): array {
|
||||||
|
if ($target === 'all') {
|
||||||
|
return $instances;
|
||||||
|
}
|
||||||
|
if (isset($instances[$target])) {
|
||||||
|
return [$target => $instances[$target]];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
$aggregateTopList = function (array $items, array &$bucket): void {
|
||||||
|
foreach ($items as $label => $count) {
|
||||||
|
if (!is_string($label)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$bucket[$label] = ($bucket[$label] ?? 0) + (int)$count;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$aggregateMap = function (array $map, array &$bucket): void {
|
||||||
|
foreach ($map as $label => $count) {
|
||||||
|
if (!is_string($label)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$bucket[$label] = ($bucket[$label] ?? 0) + (int)$count;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$extractQueryTypes = function (array $data): array {
|
||||||
|
if (isset($data['querytypes']) && is_array($data['querytypes'])) {
|
||||||
|
return $data['querytypes'];
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
};
|
||||||
|
|
||||||
|
$extractForwardDestinations = function (array $data): array {
|
||||||
|
if (isset($data['forward_destinations']) && is_array($data['forward_destinations'])) {
|
||||||
|
return $data['forward_destinations'];
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
};
|
||||||
|
|
||||||
|
$extractSources = function (array $data): array {
|
||||||
|
if (isset($data['query_sources']) && is_array($data['query_sources'])) {
|
||||||
|
return $data['query_sources'];
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
};
|
||||||
|
|
||||||
|
$parseUpdates = function (?array $versions): array {
|
||||||
|
if (!$versions) {
|
||||||
|
return ['available' => false, 'details' => []];
|
||||||
|
}
|
||||||
|
$details = [];
|
||||||
|
$available = false;
|
||||||
|
foreach (['core', 'web', 'FTL'] as $key) {
|
||||||
|
$current = $versions[$key . '_current'] ?? null;
|
||||||
|
$latest = $versions[$key . '_latest'] ?? null;
|
||||||
|
$updateFlag = $versions[$key . '_update'] ?? null;
|
||||||
|
$needs = false;
|
||||||
|
if (is_string($updateFlag)) {
|
||||||
|
$needs = $updateFlag === 'true' || $updateFlag === '1';
|
||||||
|
} elseif (is_bool($updateFlag)) {
|
||||||
|
$needs = $updateFlag;
|
||||||
|
}
|
||||||
|
if ($current && $latest && $current !== $latest) {
|
||||||
|
$needs = true;
|
||||||
|
}
|
||||||
|
$details[$key] = [
|
||||||
|
'current' => $current,
|
||||||
|
'latest' => $latest,
|
||||||
|
'update' => $needs,
|
||||||
|
];
|
||||||
|
if ($needs) {
|
||||||
|
$available = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['available' => $available, 'details' => $details];
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($action === 'dashboard') {
|
||||||
|
if (empty($instances)) {
|
||||||
|
$respond(['ok' => false, 'error' => 'no_instances'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$aggregate = [
|
||||||
|
'summary' => [
|
||||||
|
'dns_queries_today' => 0,
|
||||||
|
'ads_blocked_today' => 0,
|
||||||
|
'unique_clients' => 0,
|
||||||
|
'unique_domains' => 0,
|
||||||
|
'queries_forwarded' => 0,
|
||||||
|
'queries_cached' => 0,
|
||||||
|
'status' => 'unknown',
|
||||||
|
],
|
||||||
|
'top_ads' => [],
|
||||||
|
'top_queries' => [],
|
||||||
|
'query_types' => [],
|
||||||
|
'query_sources' => [],
|
||||||
|
'forward_destinations' => [],
|
||||||
|
'recent_blocked' => [],
|
||||||
|
'updates' => ['available' => false, 'details' => []],
|
||||||
|
];
|
||||||
|
|
||||||
|
$instancePayloads = [];
|
||||||
|
$statuses = [];
|
||||||
|
|
||||||
|
foreach ($instances as $id => $instance) {
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
$summary = $apiRequest($instance, ['summaryRaw' => 1]);
|
||||||
|
if (!$summary['ok']) {
|
||||||
|
$errors[] = ['scope' => 'summary', 'error' => $summary['error'] ?? 'error'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$topItems = $apiRequest($instance, ['topItems' => 50]);
|
||||||
|
if (!$topItems['ok']) {
|
||||||
|
$errors[] = ['scope' => 'topItems', 'error' => $topItems['error'] ?? 'error'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$queryTypes = $apiRequest($instance, ['getQueryTypes' => 1]);
|
||||||
|
if (!$queryTypes['ok']) {
|
||||||
|
$errors[] = ['scope' => 'queryTypes', 'error' => $queryTypes['error'] ?? 'error'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$querySources = $apiRequest($instance, ['getQuerySources' => 1]);
|
||||||
|
if (!$querySources['ok']) {
|
||||||
|
$errors[] = ['scope' => 'querySources', 'error' => $querySources['error'] ?? 'error'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$forwardDest = $apiRequest($instance, ['getForwardDestinations' => 1]);
|
||||||
|
if (!$forwardDest['ok']) {
|
||||||
|
$errors[] = ['scope' => 'forwardDestinations', 'error' => $forwardDest['error'] ?? 'error'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$recentBlocked = $apiRequest($instance, ['recentBlocked' => 30]);
|
||||||
|
if (!$recentBlocked['ok']) {
|
||||||
|
$errors[] = ['scope' => 'recentBlocked', 'error' => $recentBlocked['error'] ?? 'error'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$versions = $apiRequest($instance, ['versions' => 1]);
|
||||||
|
if (!$versions['ok']) {
|
||||||
|
$errors[] = ['scope' => 'versions', 'error' => $versions['error'] ?? 'error'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$summaryData = $summary['ok'] ? $summary['data'] : null;
|
||||||
|
$topData = $topItems['ok'] ? $topItems['data'] : null;
|
||||||
|
$queryTypesData = $queryTypes['ok'] ? $extractQueryTypes($queryTypes['data']) : null;
|
||||||
|
$querySourcesData = $querySources['ok'] ? $extractSources($querySources['data']) : null;
|
||||||
|
$forwardDestData = $forwardDest['ok'] ? $extractForwardDestinations($forwardDest['data']) : null;
|
||||||
|
$recentData = $recentBlocked['ok'] ? $recentBlocked['data'] : null;
|
||||||
|
$versionsData = $versions['ok'] ? $versions['data'] : null;
|
||||||
|
$updates = $parseUpdates(is_array($versionsData) ? $versionsData : null);
|
||||||
|
|
||||||
|
if (is_array($summaryData)) {
|
||||||
|
$aggregate['summary']['dns_queries_today'] += (int)($summaryData['dns_queries_today'] ?? 0);
|
||||||
|
$aggregate['summary']['ads_blocked_today'] += (int)($summaryData['ads_blocked_today'] ?? 0);
|
||||||
|
$aggregate['summary']['unique_clients'] += (int)($summaryData['unique_clients'] ?? 0);
|
||||||
|
$aggregate['summary']['unique_domains'] += (int)($summaryData['unique_domains'] ?? 0);
|
||||||
|
$aggregate['summary']['queries_forwarded'] += (int)($summaryData['queries_forwarded'] ?? 0);
|
||||||
|
$aggregate['summary']['queries_cached'] += (int)($summaryData['queries_cached'] ?? 0);
|
||||||
|
$status = (string)($summaryData['status'] ?? 'unknown');
|
||||||
|
$statuses[] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($topData)) {
|
||||||
|
if (!empty($topData['top_ads']) && is_array($topData['top_ads'])) {
|
||||||
|
$aggregateTopList($topData['top_ads'], $aggregate['top_ads']);
|
||||||
|
}
|
||||||
|
if (!empty($topData['top_queries']) && is_array($topData['top_queries'])) {
|
||||||
|
$aggregateTopList($topData['top_queries'], $aggregate['top_queries']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($queryTypesData)) {
|
||||||
|
$aggregateMap($queryTypesData, $aggregate['query_types']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($querySourcesData)) {
|
||||||
|
$aggregateMap($querySourcesData, $aggregate['query_sources']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($forwardDestData)) {
|
||||||
|
$aggregateMap($forwardDestData, $aggregate['forward_destinations']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($recentData)) {
|
||||||
|
foreach ($recentData as $entry) {
|
||||||
|
if (is_string($entry)) {
|
||||||
|
$aggregate['recent_blocked'][] = ['domain' => $entry, 'instance' => $instance['name']];
|
||||||
|
} elseif (is_array($entry) && isset($entry['domain'])) {
|
||||||
|
$aggregate['recent_blocked'][] = ['domain' => (string)$entry['domain'], 'instance' => $instance['name']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($updates['available'])) {
|
||||||
|
$aggregate['updates']['available'] = true;
|
||||||
|
}
|
||||||
|
$aggregate['updates']['details'][$id] = $updates;
|
||||||
|
|
||||||
|
$instancePayloads[$id] = [
|
||||||
|
'meta' => [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $instance['name'],
|
||||||
|
'url' => $instance['url'],
|
||||||
|
'is_primary' => !empty($instance['is_primary']),
|
||||||
|
],
|
||||||
|
'summary' => $summaryData,
|
||||||
|
'top_items' => $topData,
|
||||||
|
'query_types' => $queryTypesData,
|
||||||
|
'query_sources' => $querySourcesData,
|
||||||
|
'forward_destinations' => $forwardDestData,
|
||||||
|
'recent_blocked' => $recentData,
|
||||||
|
'versions' => $versionsData,
|
||||||
|
'updates' => $updates,
|
||||||
|
'errors' => $errors,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($aggregate['summary']['dns_queries_today'] > 0) {
|
||||||
|
$aggregate['summary']['ads_percentage_today'] = round(
|
||||||
|
$aggregate['summary']['ads_blocked_today'] / $aggregate['summary']['dns_queries_today'] * 100,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$aggregate['summary']['ads_percentage_today'] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = 'unknown';
|
||||||
|
if ($statuses) {
|
||||||
|
$allEnabled = count(array_filter($statuses, fn($s) => $s === 'enabled')) === count($statuses);
|
||||||
|
$allDisabled = count(array_filter($statuses, fn($s) => $s === 'disabled')) === count($statuses);
|
||||||
|
if ($allEnabled) {
|
||||||
|
$status = 'enabled';
|
||||||
|
} elseif ($allDisabled) {
|
||||||
|
$status = 'disabled';
|
||||||
|
} else {
|
||||||
|
$status = 'partial';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$aggregate['summary']['status'] = $status;
|
||||||
|
|
||||||
|
$respond([
|
||||||
|
'ok' => true,
|
||||||
|
'ts' => time(),
|
||||||
|
'instances' => $instancePayloads,
|
||||||
|
'aggregate' => $aggregate,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'disable') {
|
||||||
|
require_admin();
|
||||||
|
$minutes = (int)($payload['minutes'] ?? 0);
|
||||||
|
$target = (string)($payload['instance'] ?? 'all');
|
||||||
|
if ($minutes <= 0) {
|
||||||
|
$respond(['ok' => false, 'error' => 'invalid_minutes'], 400);
|
||||||
|
}
|
||||||
|
$targets = $pickInstances($target);
|
||||||
|
if (!$targets) {
|
||||||
|
$respond(['ok' => false, 'error' => 'invalid_instance'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
foreach ($targets as $id => $instance) {
|
||||||
|
$result = $apiRequest($instance, ['disable' => $minutes * 60]);
|
||||||
|
$results[$id] = $result;
|
||||||
|
}
|
||||||
|
$respond(['ok' => true, 'results' => $results]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'enable') {
|
||||||
|
require_admin();
|
||||||
|
$target = (string)($payload['instance'] ?? 'all');
|
||||||
|
$targets = $pickInstances($target);
|
||||||
|
if (!$targets) {
|
||||||
|
$respond(['ok' => false, 'error' => 'invalid_instance'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
foreach ($targets as $id => $instance) {
|
||||||
|
$result = $apiRequest($instance, ['enable' => 1]);
|
||||||
|
$results[$id] = $result;
|
||||||
|
}
|
||||||
|
$respond(['ok' => true, 'results' => $results]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'gravity') {
|
||||||
|
require_admin();
|
||||||
|
$target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary');
|
||||||
|
$targets = $pickInstances($target);
|
||||||
|
if (!$targets) {
|
||||||
|
$respond(['ok' => false, 'error' => 'invalid_instance'], 400);
|
||||||
|
}
|
||||||
|
$results = [];
|
||||||
|
foreach ($targets as $id => $instance) {
|
||||||
|
$result = $apiRequest($instance, ['updateGravity' => 1]);
|
||||||
|
$results[$id] = $result;
|
||||||
|
}
|
||||||
|
$respond(['ok' => true, 'results' => $results]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'domain_add') {
|
||||||
|
require_admin();
|
||||||
|
$domain = trim((string)($payload['domain'] ?? ''));
|
||||||
|
$type = (string)($payload['type'] ?? 'block');
|
||||||
|
if ($domain === '') {
|
||||||
|
$respond(['ok' => false, 'error' => 'missing_domain'], 400);
|
||||||
|
}
|
||||||
|
$target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary');
|
||||||
|
$targets = $pickInstances($target);
|
||||||
|
if (!$targets) {
|
||||||
|
$respond(['ok' => false, 'error' => 'invalid_instance'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = $type === 'allow'
|
||||||
|
? ['list' => 'white', 'add' => $domain]
|
||||||
|
: ['list' => 'black', 'add' => $domain];
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
foreach ($targets as $id => $instance) {
|
||||||
|
$result = $apiRequest($instance, $params);
|
||||||
|
$results[$id] = $result;
|
||||||
|
}
|
||||||
|
$respond(['ok' => true, 'results' => $results]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'adlist_add') {
|
||||||
|
require_admin();
|
||||||
|
$url = trim((string)($payload['url'] ?? ''));
|
||||||
|
if ($url === '') {
|
||||||
|
$respond(['ok' => false, 'error' => 'missing_url'], 400);
|
||||||
|
}
|
||||||
|
$target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary');
|
||||||
|
$targets = $pickInstances($target);
|
||||||
|
if (!$targets) {
|
||||||
|
$respond(['ok' => false, 'error' => 'invalid_instance'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
foreach ($targets as $id => $instance) {
|
||||||
|
$result = $apiRequest($instance, ['list' => 'adlist', 'add' => $url]);
|
||||||
|
$results[$id] = $result;
|
||||||
|
}
|
||||||
|
$respond(['ok' => true, 'results' => $results]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'update') {
|
||||||
|
require_admin();
|
||||||
|
$target = (string)($payload['instance'] ?? 'primary');
|
||||||
|
$targets = $pickInstances($target);
|
||||||
|
if (!$targets) {
|
||||||
|
$respond(['ok' => false, 'error' => 'invalid_instance'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
foreach ($targets as $id => $instance) {
|
||||||
|
$result = $apiRequest($instance, ['update' => 1]);
|
||||||
|
$results[$id] = $result;
|
||||||
|
}
|
||||||
|
$respond(['ok' => true, 'results' => $results]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$respond(['ok' => false, 'error' => 'unknown_action'], 400);
|
||||||
30
modules/pihole/pages/asset.php
Normal file
30
modules/pihole/pages/asset.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
$file = (string)($_GET['file'] ?? '');
|
||||||
|
$base = realpath(__DIR__ . '/../assets');
|
||||||
|
$map = [
|
||||||
|
'pihole.css' => $base . '/pihole.css',
|
||||||
|
'pihole.js' => $base . '/pihole.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!isset($map[$file])) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit('Not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $map[$file];
|
||||||
|
if (!$base || !is_file($path) || !str_starts_with($path, $base)) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit('Not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = pathinfo($path, PATHINFO_EXTENSION);
|
||||||
|
if ($ext === 'css') {
|
||||||
|
header('Content-Type: text/css; charset=utf-8');
|
||||||
|
} elseif ($ext === 'js') {
|
||||||
|
header('Content-Type: application/javascript; charset=utf-8');
|
||||||
|
} else {
|
||||||
|
header('Content-Type: application/octet-stream');
|
||||||
|
}
|
||||||
|
|
||||||
|
readfile($path);
|
||||||
|
exit;
|
||||||
114
modules/pihole/pages/index.php
Normal file
114
modules/pihole/pages/index.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
$assets = app()->assets();
|
||||||
|
$assets->addStyle('/module/pihole/asset?file=pihole.css');
|
||||||
|
$assets->addScript('/module/pihole/asset?file=pihole.js', 'footer', true);
|
||||||
|
|
||||||
|
$instances = module_fn('pihole', 'instances');
|
||||||
|
$hasConfig = !empty($instances);
|
||||||
|
?>
|
||||||
|
<div class="card pihole-page" data-pihole-page="dashboard">
|
||||||
|
<div class="pill">Pi-hole</div>
|
||||||
|
<h1 style="margin-top:.75rem;">Pi-hole Dashboard</h1>
|
||||||
|
<p class="muted">Status, Blockings, Usage und Steuerung fuer beide Instanzen.</p>
|
||||||
|
|
||||||
|
<?php if (!$hasConfig): ?>
|
||||||
|
<div class="card" style="margin-top:1rem; border-color:var(--accent);">
|
||||||
|
<strong>Setup erforderlich</strong>
|
||||||
|
<div class="muted" style="margin-top:.35rem;">Bitte zuerst die Pi-hole Instanzen im Setup konfigurieren.</div>
|
||||||
|
<div style="margin-top:.75rem;"><a class="nav-link" href="/modules/setup/pihole">Setup oeffnen</a></div>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="pihole-grid" style="margin-top:1rem;">
|
||||||
|
<div class="card pihole-stat">
|
||||||
|
<div class="muted">DNS Queries (heute)</div>
|
||||||
|
<div class="pihole-stat-value" data-summary-dns>–</div>
|
||||||
|
</div>
|
||||||
|
<div class="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="card pihole-stat">
|
||||||
|
<div class="muted">Unique Clients</div>
|
||||||
|
<div class="pihole-stat-value" data-summary-clients>–</div>
|
||||||
|
</div>
|
||||||
|
<div class="card pihole-stat">
|
||||||
|
<div class="muted">Status</div>
|
||||||
|
<div class="pihole-stat-value" data-summary-status>–</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:1.25rem;">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:1.25rem;">
|
||||||
|
<div class="pihole-section-header">
|
||||||
|
<strong>Instanzen</strong>
|
||||||
|
<span class="muted">Einzeln steuerbar & getrennte Updates</span>
|
||||||
|
</div>
|
||||||
|
<div class="pihole-instance-grid" data-instance-cards></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:1.25rem;">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template id="pihole-instance-template">
|
||||||
|
<div class="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>
|
||||||
|
</template>
|
||||||
86
modules/pihole/pages/lists.php
Normal file
86
modules/pihole/pages/lists.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
$assets = app()->assets();
|
||||||
|
$assets->addStyle('/module/pihole/asset?file=pihole.css');
|
||||||
|
$assets->addScript('/module/pihole/asset?file=pihole.js', 'footer', true);
|
||||||
|
|
||||||
|
$instances = module_fn('pihole', 'instances');
|
||||||
|
$hasConfig = !empty($instances);
|
||||||
|
?>
|
||||||
|
<div class="card pihole-page" data-pihole-page="lists">
|
||||||
|
<div class="pill">Pi-hole</div>
|
||||||
|
<h1 style="margin-top:.75rem;">Listen & Domains</h1>
|
||||||
|
<p class="muted">Top-Domains, Listen-Updates und neue Eintraege (Primaer-Instanz).</p>
|
||||||
|
|
||||||
|
<?php if (!$hasConfig): ?>
|
||||||
|
<div class="card" style="margin-top:1rem; border-color:var(--accent);">
|
||||||
|
<strong>Setup erforderlich</strong>
|
||||||
|
<div class="muted" style="margin-top:.35rem;">Bitte zuerst die Pi-hole Instanzen im Setup konfigurieren.</div>
|
||||||
|
<div style="margin-top:.75rem;"><a class="nav-link" href="/modules/setup/pihole">Setup oeffnen</a></div>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="card" style="margin-top:1rem;">
|
||||||
|
<div class="pihole-section-header">
|
||||||
|
<strong>Listen-Updates</strong>
|
||||||
|
<span class="muted">Gravity / Blocklisten neu laden</span>
|
||||||
|
</div>
|
||||||
|
<div class="pihole-actions" data-list-actions>
|
||||||
|
<button class="cta-button" data-action="gravity" data-instance="primary">Listen aktualisieren (Primaer)</button>
|
||||||
|
</div>
|
||||||
|
<div class="pihole-update" data-list-update-status></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pihole-split" style="margin-top:1.25rem;">
|
||||||
|
<div class="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="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>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:1.25rem;">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:1.25rem;">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
38
modules/pihole/pages/queries.php
Normal file
38
modules/pihole/pages/queries.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
$assets = app()->assets();
|
||||||
|
$assets->addStyle('/module/pihole/asset?file=pihole.css');
|
||||||
|
$assets->addScript('/module/pihole/asset?file=pihole.js', 'footer', true);
|
||||||
|
|
||||||
|
$instances = module_fn('pihole', 'instances');
|
||||||
|
$hasConfig = !empty($instances);
|
||||||
|
?>
|
||||||
|
<div class="card pihole-page" data-pihole-page="queries">
|
||||||
|
<div class="pill">Pi-hole</div>
|
||||||
|
<h1 style="margin-top:.75rem;">Zugriffe & Blockings</h1>
|
||||||
|
<p class="muted">Aktuelle Blockings, Top Clients und Status pro Instanz.</p>
|
||||||
|
|
||||||
|
<?php if (!$hasConfig): ?>
|
||||||
|
<div class="card" style="margin-top:1rem; border-color:var(--accent);">
|
||||||
|
<strong>Setup erforderlich</strong>
|
||||||
|
<div class="muted" style="margin-top:.35rem;">Bitte zuerst die Pi-hole Instanzen im Setup konfigurieren.</div>
|
||||||
|
<div style="margin-top:.75rem;"><a class="nav-link" href="/modules/setup/pihole">Setup oeffnen</a></div>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="pihole-split" style="margin-top:1rem;">
|
||||||
|
<div class="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="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>
|
||||||
Reference in New Issue
Block a user