New desktop
This commit is contained in:
351
temp/nexus-module-import/modules/pihole/assets/pihole.css
Normal file
351
temp/nexus-module-import/modules/pihole/assets/pihole.css
Normal file
@@ -0,0 +1,351 @@
|
||||
.pihole-page .card { background: var(--panel); }
|
||||
|
||||
.pihole-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pihole-stat {
|
||||
padding: 16px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.pihole-stat-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.pihole-stat-sub {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pihole-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pihole-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pihole-inline {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pihole-inline input {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.pihole-instance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-instance {
|
||||
padding: 16px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.pihole-instance-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pihole-instance-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-instance-value {
|
||||
font-weight: 700;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pihole-status {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
background: rgba(0, 179, 164, 0.15);
|
||||
color: #0a6b63;
|
||||
}
|
||||
|
||||
.pihole-status.is-disabled {
|
||||
background: rgba(255, 90, 61, 0.15);
|
||||
color: #a83a28;
|
||||
}
|
||||
|
||||
.pihole-status.is-partial {
|
||||
background: rgba(255, 166, 0, 0.18);
|
||||
color: #8a5a00;
|
||||
}
|
||||
|
||||
.pihole-split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pihole-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-list-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.pihole-list-row strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pihole-update {
|
||||
margin-top: 10px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pihole-error {
|
||||
margin-top: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: #a83a28;
|
||||
}
|
||||
|
||||
.pihole-blocked {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-blocked-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 90, 61, 0.08);
|
||||
}
|
||||
|
||||
.pihole-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pihole-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pihole-page.is-busy {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pihole-busy-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(10, 14, 24, 0.28);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.pihole-busy-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pihole-busy-card {
|
||||
min-width: min(320px, 92vw);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.pihole-busy-card span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pihole-console-modal {
|
||||
width: min(720px, 96vw);
|
||||
}
|
||||
|
||||
.pihole-console-body {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 48vh;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.pihole-console-line {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.pihole-console-line span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pihole-console-line strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pihole-console-line.is-success {
|
||||
border-color: rgba(0, 179, 164, 0.22);
|
||||
background: rgba(0, 179, 164, 0.08);
|
||||
}
|
||||
|
||||
.pihole-console-line.is-error {
|
||||
border-color: rgba(255, 90, 61, 0.24);
|
||||
background: rgba(255, 90, 61, 0.08);
|
||||
}
|
||||
|
||||
.pihole-instance-card {
|
||||
padding: 16px;
|
||||
background: var(--panel-2);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pihole-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pihole-test-result {
|
||||
margin-top: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pihole-test-result.is-ok { color: #0a6b63; }
|
||||
.pihole-test-result.is-auth { color: #a83a28; }
|
||||
.pihole-test-result.is-unreachable { color: #a83a28; }
|
||||
.pihole-test-result.is-invalid { color: #8a5a00; }
|
||||
.pihole-test-result.is-error { color: #a83a28; }
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pihole-instance-form {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.pihole-instance-form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.pihole-form-field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.pihole-checkbox-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pihole-checkbox-field input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pihole-modal-card {
|
||||
width: min(760px, 96vw);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.pihole-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
border-radius: 10px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.pihole-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.pihole-inline {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pihole-inline input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pihole-instance-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
507
temp/nexus-module-import/modules/pihole/assets/pihole.js
Normal file
507
temp/nexus-module-import/modules/pihole/assets/pihole.js
Normal file
@@ -0,0 +1,507 @@
|
||||
(() => {
|
||||
const page = document.querySelector('[data-pihole-page]');
|
||||
if (!page) return;
|
||||
|
||||
const pageType = page.dataset.piholePage || 'dashboard';
|
||||
const configuredRefreshSeconds = Number(page.dataset.refreshSeconds || 0);
|
||||
const defaultRefreshSeconds = pageType === 'dashboard' ? 1 : 5;
|
||||
const refreshSeconds = Number.isFinite(configuredRefreshSeconds) && configuredRefreshSeconds >= 0
|
||||
? configuredRefreshSeconds
|
||||
: defaultRefreshSeconds;
|
||||
|
||||
const fmt = new Intl.NumberFormat('de-DE');
|
||||
const fmtDate = (ts) => {
|
||||
if (!ts) return '–';
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleString('de-DE');
|
||||
};
|
||||
|
||||
let refreshTimer = null;
|
||||
let loadInFlight = false;
|
||||
let actionInFlight = false;
|
||||
let actionConsoleApi = null;
|
||||
let actionConsoleBody = null;
|
||||
let actionConsoleClose = null;
|
||||
|
||||
const apiCall = async (action, payload = {}) => {
|
||||
const res = await fetch(`/module/pihole/api?action=${encodeURIComponent(action)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
if (data && data.results && typeof data.results === 'object') {
|
||||
const failures = Object.values(data.results).filter((row) => row && row.ok === false);
|
||||
if (failures.length) {
|
||||
const first = failures[0];
|
||||
throw new Error(first.error || 'action_failed');
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const setText = (el, value) => {
|
||||
if (!el) return;
|
||||
el.textContent = value;
|
||||
};
|
||||
|
||||
const ensureActionConsole = () => {
|
||||
if (actionConsoleApi && actionConsoleBody && actionConsoleClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = 'modal';
|
||||
root.dataset.piholeConsoleModal = '1';
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
root.innerHTML = `
|
||||
<div class="modal-card pihole-console-modal" role="dialog" aria-modal="true" aria-labelledby="pihole-console-title">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<strong id="pihole-console-title">Pi-hole Aktion</strong>
|
||||
<div class="muted">Status und Rueckmeldungen zur laufenden Aktion.</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="module-button module-button--secondary module-button--small" type="button" data-pihole-console-clear>Leeren</button>
|
||||
<button class="icon-button" type="button" data-pihole-console-close aria-label="Konsole schliessen">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pihole-console-body" data-pihole-console-body></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(root);
|
||||
|
||||
actionConsoleBody = root.querySelector('[data-pihole-console-body]');
|
||||
actionConsoleClose = root.querySelector('[data-pihole-console-close]');
|
||||
const clearBtn = root.querySelector('[data-pihole-console-clear]');
|
||||
|
||||
actionConsoleApi = window.NexusModal && typeof window.NexusModal.create === 'function'
|
||||
? window.NexusModal.create(root, { initialFocus: '[data-pihole-console-close]' })
|
||||
: {
|
||||
open() {
|
||||
root.classList.add('is-open');
|
||||
root.setAttribute('aria-hidden', 'false');
|
||||
},
|
||||
close() {
|
||||
root.classList.remove('is-open');
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
},
|
||||
};
|
||||
|
||||
if (actionConsoleClose) {
|
||||
actionConsoleClose.addEventListener('click', () => {
|
||||
if (!actionInFlight) {
|
||||
actionConsoleApi.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
if (actionConsoleBody) {
|
||||
actionConsoleBody.innerHTML = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const appendActionLog = (message, tone = 'info') => {
|
||||
ensureActionConsole();
|
||||
if (!actionConsoleBody) {
|
||||
return;
|
||||
}
|
||||
const row = document.createElement('div');
|
||||
row.className = `pihole-console-line is-${tone}`;
|
||||
row.innerHTML = `<span>${new Date().toLocaleTimeString('de-DE')}</span><strong>${message}</strong>`;
|
||||
actionConsoleBody.appendChild(row);
|
||||
actionConsoleBody.scrollTop = actionConsoleBody.scrollHeight;
|
||||
};
|
||||
|
||||
const setActionLock = (locked, message = 'Bitte warten ...') => {
|
||||
actionInFlight = locked;
|
||||
page.classList.toggle('is-busy', locked);
|
||||
ensureActionConsole();
|
||||
if (actionConsoleClose) {
|
||||
actionConsoleClose.disabled = locked;
|
||||
}
|
||||
|
||||
page.querySelectorAll('button, input, select, textarea').forEach((el) => {
|
||||
const formControl = el;
|
||||
if (locked) {
|
||||
formControl.dataset.piholeWasDisabled = formControl.disabled ? 'true' : 'false';
|
||||
formControl.disabled = true;
|
||||
return;
|
||||
}
|
||||
formControl.disabled = formControl.dataset.piholeWasDisabled === 'true';
|
||||
delete formControl.dataset.piholeWasDisabled;
|
||||
});
|
||||
};
|
||||
|
||||
const statusLabel = (status) => {
|
||||
if (status === 'enabled') return 'Aktiv';
|
||||
if (status === 'disabled') return 'Deaktiviert';
|
||||
if (status === 'partial') return 'Teilweise';
|
||||
return 'Unbekannt';
|
||||
};
|
||||
|
||||
const setStatusBadge = (el, status) => {
|
||||
if (!el) return;
|
||||
el.textContent = statusLabel(status);
|
||||
el.classList.remove('is-disabled', 'is-partial');
|
||||
if (status === 'disabled') el.classList.add('is-disabled');
|
||||
if (status === 'partial') el.classList.add('is-partial');
|
||||
};
|
||||
|
||||
const mapToList = (map, limit = 10) => {
|
||||
if (!map || typeof map !== 'object') return [];
|
||||
const entries = Object.entries(map)
|
||||
.filter(([, value]) => typeof value === 'number' || typeof value === 'string')
|
||||
.map(([key, value]) => [key, Number(value)])
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
return entries.slice(0, limit);
|
||||
};
|
||||
|
||||
const renderList = (container, map, emptyText) => {
|
||||
if (!container) return;
|
||||
const rows = mapToList(map, 10);
|
||||
container.innerHTML = '';
|
||||
if (!rows.length) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'muted';
|
||||
div.textContent = emptyText || 'Keine Daten';
|
||||
container.appendChild(div);
|
||||
return;
|
||||
}
|
||||
rows.forEach(([label, value]) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'pihole-list-row';
|
||||
row.innerHTML = `<strong>${label}</strong><span>${fmt.format(value)}</span>`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const renderBlocked = (container, list) => {
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
if (!Array.isArray(list) || !list.length) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'muted';
|
||||
div.textContent = 'Keine Daten';
|
||||
container.appendChild(div);
|
||||
return;
|
||||
}
|
||||
list.slice(0, 20).forEach((entry) => {
|
||||
const domain = entry.domain || entry;
|
||||
const instance = entry.instance || '';
|
||||
const row = document.createElement('div');
|
||||
row.className = 'pihole-blocked-row';
|
||||
row.innerHTML = `<span>${domain}</span><span class="muted">${instance}</span>`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const renderDashboardData = (data) => {
|
||||
const summary = data.aggregate?.summary || {};
|
||||
setText(document.querySelector('[data-summary-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
|
||||
setText(document.querySelector('[data-summary-blocked]'), fmt.format(Number(summary.ads_blocked_today || 0)));
|
||||
setText(document.querySelector('[data-summary-percent]'), `${Number(summary.ads_percentage_today || 0).toFixed(2)}%`);
|
||||
setText(document.querySelector('[data-summary-clients]'), fmt.format(Number(summary.unique_clients || 0)));
|
||||
setStatusBadge(document.querySelector('[data-summary-status]'), summary.status || 'unknown');
|
||||
setText(document.querySelector('[data-summary-last-refresh]'), `Letztes Update: ${fmtDate(data.ts)}`);
|
||||
|
||||
renderInstances(data.instances || {});
|
||||
renderList(document.querySelector('[data-query-types]'), data.aggregate?.query_types, 'Keine Daten');
|
||||
renderList(document.querySelector('[data-forward-destinations]'), data.aggregate?.forward_destinations, 'Keine Daten');
|
||||
renderList(document.querySelector('[data-top-ads]'), data.aggregate?.top_ads, 'Keine Daten');
|
||||
renderList(document.querySelector('[data-top-queries]'), data.aggregate?.top_queries, 'Keine Daten');
|
||||
renderList(document.querySelector('[data-top-clients]'), data.aggregate?.query_sources, 'Keine Daten');
|
||||
renderBlocked(document.querySelector('[data-recent-blocked]'), data.aggregate?.recent_blocked);
|
||||
};
|
||||
|
||||
const dashboardFingerprint = (data) => {
|
||||
const aggregate = data?.aggregate?.summary || {};
|
||||
const instanceSummary = Object.values(data?.instances || {}).map((entry) => ({
|
||||
id: entry?.meta?.id || '',
|
||||
blocked_domains: Number(entry?.summary?.blocked_domains || 0),
|
||||
ads_blocked_today: Number(entry?.summary?.ads_blocked_today || 0),
|
||||
unique_domains: Number(entry?.summary?.unique_domains || 0),
|
||||
}));
|
||||
|
||||
return JSON.stringify({
|
||||
blocked_domains: Number(aggregate.blocked_domains || 0),
|
||||
ads_blocked_today: Number(aggregate.ads_blocked_today || 0),
|
||||
unique_domains: Number(aggregate.unique_domains || 0),
|
||||
instances: instanceSummary,
|
||||
});
|
||||
};
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
const monitorGravityProgress = async (baselineData, instanceLabel) => {
|
||||
const baselineFingerprint = dashboardFingerprint(baselineData || {});
|
||||
const maxPolls = 24;
|
||||
const pollDelayMs = 5000;
|
||||
|
||||
appendActionLog(`Pi-hole meldet keinen Live-Fortschritt. Pruefe jetzt zyklisch auf erkennbare Aenderungen fuer ${instanceLabel}.`, 'info');
|
||||
|
||||
for (let attempt = 1; attempt <= maxPolls; attempt += 1) {
|
||||
await delay(pollDelayMs);
|
||||
appendActionLog(`Pruefung ${attempt}/${maxPolls}: aktuelle Statusdaten werden geladen ...`, 'info');
|
||||
|
||||
try {
|
||||
const data = await apiCall('dashboard');
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error || 'API error');
|
||||
}
|
||||
|
||||
renderDashboardData(data);
|
||||
const nextFingerprint = dashboardFingerprint(data);
|
||||
if (nextFingerprint !== baselineFingerprint) {
|
||||
appendActionLog(`Erkennbare Aenderung gefunden. Listen-Update fuer ${instanceLabel} scheint abgeschlossen zu sein.`, 'success');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attempt % 3 === 0) {
|
||||
appendActionLog('Noch keine erkennbare Aenderung in den Pi-hole Statusdaten.', 'info');
|
||||
}
|
||||
} catch (err) {
|
||||
appendActionLog(`Statuspruefung fehlgeschlagen: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
appendActionLog(`Innerhalb des Beobachtungsfensters wurde keine erkennbare Aenderung gefunden. Das Update fuer ${instanceLabel} kann trotzdem weiterlaufen oder bereits ohne sichtbare Statistikaenderung beendet worden sein.`, 'error');
|
||||
return false;
|
||||
};
|
||||
|
||||
const renderInstances = (instances) => {
|
||||
const holder = document.querySelector('[data-instance-cards]');
|
||||
const tpl = document.querySelector('#pihole-instance-template');
|
||||
if (!holder || !tpl) return;
|
||||
holder.innerHTML = '';
|
||||
|
||||
Object.values(instances).forEach((entry) => {
|
||||
const node = tpl.content.cloneNode(true);
|
||||
const root = node.querySelector('[data-instance]');
|
||||
if (!root) return;
|
||||
root.dataset.instance = entry.meta.id;
|
||||
|
||||
const summary = entry.summary || {};
|
||||
setText(root.querySelector('[data-instance-name]'), entry.meta.name || entry.meta.id);
|
||||
setText(root.querySelector('[data-instance-url]'), entry.meta.url || '');
|
||||
setText(root.querySelector('[data-instance-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
|
||||
setText(root.querySelector('[data-instance-ads]'), fmt.format(Number(summary.ads_blocked_today || 0)));
|
||||
const percent = summary.ads_percentage_today || summary.ads_percentage || 0;
|
||||
setText(root.querySelector('[data-instance-percent]'), `${Number(percent).toFixed(2)}%`);
|
||||
setStatusBadge(root.querySelector('[data-instance-status]'), summary.status || 'unknown');
|
||||
|
||||
const actions = root.querySelectorAll('[data-instance]');
|
||||
actions.forEach((btn) => {
|
||||
if (btn.dataset.instance === '') {
|
||||
btn.dataset.instance = entry.meta.id;
|
||||
}
|
||||
});
|
||||
const customInput = root.querySelector('[data-custom-minutes]');
|
||||
if (customInput) {
|
||||
customInput.dataset.customMinutes = entry.meta.id;
|
||||
}
|
||||
|
||||
const updateEl = root.querySelector('[data-instance-update]');
|
||||
if (entry.updates && entry.updates.available) {
|
||||
updateEl.textContent = 'Updates verfuegbar';
|
||||
} else {
|
||||
updateEl.textContent = 'Keine Updates erkannt';
|
||||
}
|
||||
|
||||
const errorEl = root.querySelector('[data-instance-errors]');
|
||||
if (errorEl) {
|
||||
if (Array.isArray(entry.errors) && entry.errors.length) {
|
||||
const lines = entry.errors.map((err) => {
|
||||
const code = err.http_code ? `HTTP ${err.http_code}` : 'HTTP ?';
|
||||
const scope = err.scope || 'request';
|
||||
const msg = err.error || 'error';
|
||||
const hint = err.hint ? `, Hint: ${typeof err.hint === 'string' ? err.hint : JSON.stringify(err.hint)}` : '';
|
||||
return `${scope}: ${msg} (${code}${hint})`;
|
||||
});
|
||||
errorEl.textContent = `API Fehler: ${lines.join(' | ')}`;
|
||||
} else {
|
||||
errorEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
holder.appendChild(node);
|
||||
});
|
||||
};
|
||||
|
||||
const bindActionButtons = () => {
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
const instance = btn.dataset.instance || 'all';
|
||||
const minutes = btn.dataset.minutes;
|
||||
const scope = btn.closest('.pihole-instance') || document;
|
||||
const customInput = scope.querySelector(`[data-custom-minutes="${instance}"]`);
|
||||
let payload = { instance };
|
||||
let baselineData = null;
|
||||
|
||||
if (action === 'disable') {
|
||||
payload.minutes = Number(minutes || 0);
|
||||
}
|
||||
if (action === 'disable-custom') {
|
||||
payload.minutes = Number(customInput?.value || 0);
|
||||
}
|
||||
|
||||
try {
|
||||
const actionLabel = action === 'enable'
|
||||
? 'Pi-hole wird aktiviert ...'
|
||||
: action === 'disable' || action === 'disable-custom'
|
||||
? 'Pi-hole wird deaktiviert ...'
|
||||
: action === 'gravity'
|
||||
? 'Listen werden aktualisiert ...'
|
||||
: 'Aktion wird ausgefuehrt ...';
|
||||
ensureActionConsole();
|
||||
if (actionConsoleBody) {
|
||||
actionConsoleBody.innerHTML = '';
|
||||
}
|
||||
actionConsoleApi.open();
|
||||
appendActionLog(actionLabel, 'info');
|
||||
setActionLock(true, actionLabel);
|
||||
|
||||
baselineData = action === 'gravity' ? await apiCall('dashboard').catch(() => null) : null;
|
||||
|
||||
if (action === 'enable') {
|
||||
appendActionLog(`Aktiviere ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info');
|
||||
await apiCall('enable', payload);
|
||||
} else if (action === 'disable' || action === 'disable-custom') {
|
||||
if (!payload.minutes || payload.minutes <= 0) {
|
||||
appendActionLog('Fehler: Bitte Minuten angeben.', 'error');
|
||||
return;
|
||||
}
|
||||
appendActionLog(`Deaktiviere ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`} fuer ${payload.minutes} Minuten.`, 'info');
|
||||
await apiCall('disable', payload);
|
||||
} else if (action === 'gravity') {
|
||||
appendActionLog(`Starte Listen-Update fuer ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info');
|
||||
await apiCall('gravity', payload);
|
||||
const status = document.querySelector('[data-list-update-status]');
|
||||
if (status) status.textContent = 'Listen-Update gestartet.';
|
||||
} else if (action === 'update') {
|
||||
appendActionLog(`Starte Pi-hole-Update fuer ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info');
|
||||
await apiCall('update', payload);
|
||||
}
|
||||
appendActionLog('Aktion abgeschlossen. Dashboard wird aktualisiert.', 'success');
|
||||
if (action === 'gravity') {
|
||||
await monitorGravityProgress(baselineData, instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`);
|
||||
} else {
|
||||
await loadDashboard();
|
||||
appendActionLog('Anzeige erfolgreich aktualisiert.', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
appendActionLog(`Aktion fehlgeschlagen: ${err.message}`, 'error');
|
||||
if (action === 'gravity' && /timed out/i.test(err.message)) {
|
||||
appendActionLog('Der Request ist in Nexus abgelaufen. Pi-hole kann intern trotzdem weiterarbeiten. Starte weiterfuehrende Statuspruefung.', 'info');
|
||||
await monitorGravityProgress(baselineData, instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`);
|
||||
}
|
||||
} finally {
|
||||
setActionLock(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const bindForms = () => {
|
||||
const domainForm = document.querySelector('[data-domain-form]');
|
||||
if (domainForm) {
|
||||
domainForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(domainForm);
|
||||
const type = String(formData.get('type') || 'block');
|
||||
const domain = String(formData.get('domain') || '').trim();
|
||||
const status = document.querySelector('[data-domain-status]');
|
||||
if (!domain) return;
|
||||
try {
|
||||
await apiCall('domain_add', { type, domain, instance: 'primary' });
|
||||
if (status) status.textContent = `Domain hinzugefuegt: ${domain}`;
|
||||
domainForm.reset();
|
||||
} catch (err) {
|
||||
if (status) status.textContent = `Fehler: ${err.message}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const adlistForm = document.querySelector('[data-adlist-form]');
|
||||
if (adlistForm) {
|
||||
adlistForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(adlistForm);
|
||||
const url = String(formData.get('url') || '').trim();
|
||||
const status = document.querySelector('[data-adlist-status]');
|
||||
if (!url) return;
|
||||
try {
|
||||
await apiCall('adlist_add', { url, instance: 'primary' });
|
||||
if (status) status.textContent = `Adlist hinzugefuegt: ${url}`;
|
||||
adlistForm.reset();
|
||||
} catch (err) {
|
||||
if (status) status.textContent = `Fehler: ${err.message}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const loadDashboard = async () => {
|
||||
if (loadInFlight || actionInFlight) return;
|
||||
loadInFlight = true;
|
||||
try {
|
||||
const data = await apiCall('dashboard');
|
||||
if (!data.ok) throw new Error(data.error || 'API error');
|
||||
renderDashboardData(data);
|
||||
const existing = page.querySelector('[data-pihole-load-error]');
|
||||
if (existing) existing.remove();
|
||||
} catch (err) {
|
||||
let message = page.querySelector('[data-pihole-load-error]');
|
||||
if (!message) {
|
||||
message = document.createElement('div');
|
||||
message.className = 'card';
|
||||
message.style.marginTop = '1rem';
|
||||
message.dataset.piholeLoadError = '1';
|
||||
page.appendChild(message);
|
||||
}
|
||||
message.textContent = `Fehler beim Laden der Pi-hole Daten: ${err.message}`;
|
||||
} finally {
|
||||
loadInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshTimer !== null) {
|
||||
window.clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
stopAutoRefresh();
|
||||
if (refreshSeconds <= 0 || document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
refreshTimer = window.setInterval(loadDashboard, refreshSeconds * 1000);
|
||||
};
|
||||
|
||||
bindActionButtons();
|
||||
bindForms();
|
||||
loadDashboard();
|
||||
startAutoRefresh();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
loadDashboard();
|
||||
startAutoRefresh();
|
||||
return;
|
||||
}
|
||||
stopAutoRefresh();
|
||||
});
|
||||
window.addEventListener('beforeunload', stopAutoRefresh);
|
||||
})();
|
||||
@@ -0,0 +1,141 @@
|
||||
(() => {
|
||||
const modal = document.querySelector('[data-instance-modal]');
|
||||
const form = document.querySelector('[data-instance-form]');
|
||||
|
||||
const apiCall = async (action, payload = {}) => {
|
||||
const res = await fetch(`/module/pihole/api?action=${encodeURIComponent(action)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
document.addEventListener('click', async (event) => {
|
||||
const testBtn = event.target.closest('[data-instance-test]');
|
||||
if (!testBtn) return;
|
||||
|
||||
const card = testBtn.closest('[data-instance-id]');
|
||||
if (!card) return;
|
||||
|
||||
const instanceId = card.dataset.instanceId || '';
|
||||
const resultEl = card.querySelector('[data-instance-result]');
|
||||
if (resultEl) {
|
||||
resultEl.classList.remove('is-ok', 'is-auth', 'is-unreachable', 'is-invalid', 'is-error');
|
||||
resultEl.textContent = 'Teste Verbindung...';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiCall('test', { instance: instanceId });
|
||||
if (resultEl) {
|
||||
const statusClass = res.status ? `is-${res.status}` : 'is-ok';
|
||||
resultEl.classList.add(statusClass);
|
||||
resultEl.textContent = res.message || 'Verbindung OK.';
|
||||
}
|
||||
} catch (err) {
|
||||
if (resultEl) {
|
||||
const msg = err.message || 'Fehler';
|
||||
resultEl.classList.add('is-error');
|
||||
resultEl.textContent = `Test fehlgeschlagen: ${msg}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!modal || !form) return;
|
||||
|
||||
const title = document.querySelector('[data-instance-modal-title]');
|
||||
const closeBtn = document.querySelector('[data-instance-close]');
|
||||
const newBtn = document.querySelector('[data-instance-new]');
|
||||
const cancelBtn = document.querySelector('[data-instance-cancel]');
|
||||
|
||||
const currentIdInput = form.querySelector('input[name="current_id"]');
|
||||
const idInput = form.querySelector('input[name="instance_id"]');
|
||||
const nameInput = form.querySelector('input[name="name"]');
|
||||
const urlInput = form.querySelector('input[name="url"]');
|
||||
const passwordInput = form.querySelector('input[name="password"]');
|
||||
const primaryInput = form.querySelector('input[name="is_primary"]');
|
||||
|
||||
const resetForm = () => {
|
||||
if (currentIdInput) currentIdInput.value = '';
|
||||
if (idInput) idInput.value = '';
|
||||
if (nameInput) nameInput.value = '';
|
||||
if (urlInput) urlInput.value = '';
|
||||
if (passwordInput) passwordInput.value = '';
|
||||
if (primaryInput) primaryInput.checked = false;
|
||||
};
|
||||
|
||||
const modalApi = window.NexusModal && typeof window.NexusModal.create === 'function'
|
||||
? window.NexusModal.create(modal, { initialFocus: 'input[name="instance_id"]' })
|
||||
: null;
|
||||
|
||||
const openModal = () => {
|
||||
if (modalApi) {
|
||||
modalApi.open();
|
||||
return;
|
||||
}
|
||||
modal.classList.add('is-open');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
if (idInput) {
|
||||
window.setTimeout(() => idInput.focus(), 20);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (modalApi) {
|
||||
modalApi.close();
|
||||
return;
|
||||
}
|
||||
modal.classList.remove('is-open');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-instance-edit]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const card = btn.closest('[data-instance-id]');
|
||||
if (!card) return;
|
||||
if (currentIdInput) currentIdInput.value = card.dataset.instanceId || '';
|
||||
if (idInput) idInput.value = card.dataset.instanceId || '';
|
||||
if (nameInput) nameInput.value = card.dataset.name || '';
|
||||
if (urlInput) urlInput.value = card.dataset.url || '';
|
||||
if (passwordInput) passwordInput.value = '';
|
||||
if (primaryInput) primaryInput.checked = card.dataset.primary === '1';
|
||||
if (title) title.textContent = 'Instanz bearbeiten';
|
||||
openModal();
|
||||
});
|
||||
});
|
||||
|
||||
if (newBtn) {
|
||||
newBtn.addEventListener('click', () => {
|
||||
resetForm();
|
||||
if (title) title.textContent = 'Neue Instanz';
|
||||
openModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => closeModal());
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
resetForm();
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (!modalApi) {
|
||||
modal.addEventListener('click', (event) => {
|
||||
if (event.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && modal.classList.contains('is-open')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
116
temp/nexus-module-import/modules/pihole/bootstrap.php
Normal file
116
temp/nexus-module-import/modules/pihole/bootstrap.php
Normal 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']);
|
||||
});
|
||||
14
temp/nexus-module-import/modules/pihole/design.json
Normal file
14
temp/nexus-module-import/modules/pihole/design.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"eyebrow": "Modul",
|
||||
"title": "Pi-hole",
|
||||
"description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.",
|
||||
"actions": [
|
||||
{ "label": "Setup", "href": "/modules/setup/pihole", "variant": "ghost" },
|
||||
{ "label": "Instanzen", "href": "/module/pihole/instances", "variant": "secondary" }
|
||||
],
|
||||
"tabs": [
|
||||
{ "label": "Dashboard", "href": "/module/pihole" },
|
||||
{ "label": "Listen", "href": "/module/pihole/lists" },
|
||||
{ "label": "Queries", "href": "/module/pihole/queries" }
|
||||
]
|
||||
}
|
||||
44
temp/nexus-module-import/modules/pihole/module.json
Normal file
44
temp/nexus-module-import/modules/pihole/module.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"title": "Pi-hole",
|
||||
"version": "0.1.0",
|
||||
"description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.",
|
||||
"setup": {
|
||||
"fields": [
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
1154
temp/nexus-module-import/modules/pihole/pages/api.php
Normal file
1154
temp/nexus-module-import/modules/pihole/pages/api.php
Normal file
File diff suppressed because it is too large
Load Diff
31
temp/nexus-module-import/modules/pihole/pages/asset.php
Normal file
31
temp/nexus-module-import/modules/pihole/pages/asset.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
$file = (string)($_GET['file'] ?? '');
|
||||
$base = realpath(__DIR__ . '/../assets');
|
||||
$map = [
|
||||
'pihole.css' => $base . '/pihole.css',
|
||||
'pihole.js' => $base . '/pihole.js',
|
||||
'pihole_instances.js' => $base . '/pihole_instances.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;
|
||||
143
temp/nexus-module-import/modules/pihole/pages/index.php
Normal file
143
temp/nexus-module-import/modules/pihole/pages/index.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
$assets = app()->assets();
|
||||
$assets->addStyle('/module/pihole/asset?file=pihole.css');
|
||||
$assets->addScript('/module/pihole/asset?file=pihole.js', 'footer', true);
|
||||
|
||||
$settings = modules()->settings('pihole');
|
||||
$instances = module_fn('pihole', 'instances');
|
||||
$hasConfig = !empty($instances);
|
||||
$refreshSeconds = (int)($settings['dashboard_refresh_sec'] ?? 1);
|
||||
if ($refreshSeconds < 0) {
|
||||
$refreshSeconds = 1;
|
||||
}
|
||||
?>
|
||||
<?= module_shell_header('pihole', [
|
||||
'title' => 'Pi-hole Dashboard',
|
||||
'description' => 'Status, Blockings, Usage und Steuerung fuer beide Instanzen.',
|
||||
]) ?>
|
||||
<div class="module-flow pihole-page" data-pihole-page="dashboard" data-refresh-seconds="<?= e((string)$refreshSeconds) ?>">
|
||||
|
||||
<section class="module-box">
|
||||
<div class="pihole-section-header">
|
||||
<strong>Hosts</strong>
|
||||
<a class="module-button module-button--secondary module-button--small" href="/module/pihole/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="module-button module-button--primary" href="/module/pihole/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="module-box-grid module-box-grid--stats pihole-grid">
|
||||
<div class="module-box-soft pihole-stat">
|
||||
<div class="muted">DNS Queries (heute)</div>
|
||||
<div class="pihole-stat-value" data-summary-dns>–</div>
|
||||
</div>
|
||||
<div class="module-box-soft 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="module-box-soft pihole-stat">
|
||||
<div class="muted">Unique Clients</div>
|
||||
<div class="pihole-stat-value" data-summary-clients>–</div>
|
||||
</div>
|
||||
<div class="module-box-soft pihole-stat">
|
||||
<div class="muted">Status</div>
|
||||
<div class="pihole-stat-value" data-summary-status>–</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="module-box">
|
||||
<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="module-box">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="module-box">
|
||||
<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="module-box-soft 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>
|
||||
<?= module_shell_footer() ?>
|
||||
416
temp/nexus-module-import/modules/pihole/pages/instances.php
Normal file
416
temp/nexus-module-import/modules/pihole/pages/instances.php
Normal file
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
$moduleName = 'pihole';
|
||||
$assets = app()->assets();
|
||||
$assets->addStyle('/module/pihole/asset?file=pihole.css');
|
||||
$assets->addScript('/module/pihole/asset?file=pihole_instances.js', 'footer', true);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<?= module_shell_header('pihole', [
|
||||
'title' => 'Pi-hole Instanzen',
|
||||
'description' => 'Pi-hole Instanzen hinzufuegen, bearbeiten und loeschen.',
|
||||
]) ?>
|
||||
<div class="module-flow">
|
||||
<section class="module-box">
|
||||
<div class="module-box-head">
|
||||
<div>
|
||||
<h2 class="module-box-title">Instanzen</h2>
|
||||
<p>Pi-hole Instanzen hinzufuegen, bearbeiten und loeschen.</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
||||
<button class="module-button module-button--primary" type="button" data-instance-new>+ Neue Instanz</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="setup-db-message setup-db-message--error" style="margin-top:16px;">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php elseif ($notice): ?>
|
||||
<div class="setup-db-message setup-db-message--success" style="margin-top:16px;">
|
||||
<?= e($notice) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="module-box-grid module-box-grid--panels" style="margin-top:16px;">
|
||||
<?php if (!$instances): ?>
|
||||
<div class="module-box-soft">Keine Instanzen vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($instances as $instance): ?>
|
||||
<div class="module-box-soft 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="module-button module-button--secondary module-button--small" type="button" data-instance-edit>Bearbeiten</button>
|
||||
<form method="post">
|
||||
<input type="hidden" name="test_id" value="<?= e((string)($instance['id'] ?? '')) ?>">
|
||||
<button class="module-button module-button--secondary module-button--small" 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="module-button module-button--secondary module-button--small" 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="cta-button" type="submit" data-instance-submit>Speichern</button>
|
||||
<button class="nav-link" type="button" data-instance-cancel>Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?= module_shell_footer() ?>
|
||||
97
temp/nexus-module-import/modules/pihole/pages/lists.php
Normal file
97
temp/nexus-module-import/modules/pihole/pages/lists.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
$assets = app()->assets();
|
||||
$assets->addStyle('/module/pihole/asset?file=pihole.css');
|
||||
$assets->addScript('/module/pihole/asset?file=pihole.js', 'footer', true);
|
||||
|
||||
$settings = modules()->settings('pihole');
|
||||
$instances = module_fn('pihole', 'instances');
|
||||
$hasConfig = !empty($instances);
|
||||
$refreshSeconds = (int)($settings['lists_refresh_sec'] ?? 5);
|
||||
if ($refreshSeconds < 0) {
|
||||
$refreshSeconds = 5;
|
||||
}
|
||||
?>
|
||||
<?= module_shell_header('pihole', [
|
||||
'title' => 'Listen & Domains',
|
||||
'description' => 'Top-Domains, Listen-Updates fuer alle Instanzen und neue Eintraege auf der Primaer-Instanz.',
|
||||
]) ?>
|
||||
<div class="module-flow pihole-page" data-pihole-page="lists" data-refresh-seconds="<?= e((string)$refreshSeconds) ?>">
|
||||
<?php if (!$hasConfig): ?>
|
||||
<div class="module-box">
|
||||
<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="module-button module-button--secondary module-button--small" href="/module/pihole/instances">Instanzen verwalten</a></div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<section class="module-box">
|
||||
<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="module-box-grid module-box-grid--panels pihole-split">
|
||||
<div class="module-box">
|
||||
<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="module-box">
|
||||
<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="module-box">
|
||||
<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="module-box">
|
||||
<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>
|
||||
<?= module_shell_footer() ?>
|
||||
44
temp/nexus-module-import/modules/pihole/pages/queries.php
Normal file
44
temp/nexus-module-import/modules/pihole/pages/queries.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
$assets = app()->assets();
|
||||
$assets->addStyle('/module/pihole/asset?file=pihole.css');
|
||||
$assets->addScript('/module/pihole/asset?file=pihole.js', 'footer', true);
|
||||
|
||||
$settings = modules()->settings('pihole');
|
||||
$instances = module_fn('pihole', 'instances');
|
||||
$hasConfig = !empty($instances);
|
||||
$refreshSeconds = (int)($settings['queries_refresh_sec'] ?? 5);
|
||||
if ($refreshSeconds < 0) {
|
||||
$refreshSeconds = 5;
|
||||
}
|
||||
?>
|
||||
<?= module_shell_header('pihole', [
|
||||
'title' => 'Zugriffe & Blockings',
|
||||
'description' => 'Aktuelle Blockings, Top Clients und Status pro Instanz.',
|
||||
]) ?>
|
||||
<div class="module-flow pihole-page" data-pihole-page="queries" data-refresh-seconds="<?= e((string)$refreshSeconds) ?>">
|
||||
<?php if (!$hasConfig): ?>
|
||||
<div class="module-box">
|
||||
<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="module-button module-button--secondary module-button--small" href="/module/pihole/instances">Instanzen verwalten</a></div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="module-box-grid module-box-grid--panels pihole-split">
|
||||
<div class="module-box">
|
||||
<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="module-box">
|
||||
<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>
|
||||
<?= module_shell_footer() ?>
|
||||
Reference in New Issue
Block a user