Hostname (Systemeinstellungen, neben Pruefintervall): - hostnamectl set-hostname ueber die App, Validierung (RFC-1123-artiges Label), kein Revert-Timer noetig (kappt die Erreichbarkeit nicht wie eine IP-Aenderung). Live getestet inkl. Validierung und Revert. DHCP: Range ist jetzt Pflicht, kein geratener Default mehr: - dhcp_range_start/end/dns starten leer (Platzhaltertext statt fake- echt aussehendem Default) - ein Zufalls-Range haette den Dienst sonst unbemerkt mit einer zum echten Netz nicht passenden Konfiguration starten lassen koennen. - write_dhcp_file und dhcp_service_action=enable_restart verweigern sich ohne eingetragene Range; _render_kea_config laesst "pools" ohne Range komplett weg statt einen kaputten Pool-String zu erzeugen. - Neues, separates dhcp_gateway-Feld (optional) fuer einen vom automatisch erkannten Gateway abweichenden Router fuer die Clients. Topbar-Ampel fuer den Kea-Dienst: - Gruener/roter Punkt neben dem Pruefintervall-Timer, nur sichtbar mit settings_dhcp.view (ein einzelner, kurzer systemctl-Aufruf pro Request, nicht die volle Status-Erkennung). Live verifiziert (rot wenn gestoppt, gruen wenn gestartet). Reservierungstabelle zeigt jetzt pro Client, welche eigene DHCP-Option greift (global oder Client-Override, mit Wert im Tooltip). Text-Straffung: die laengsten Hint-Texte und Code-Kommentare in Templates/app.py gekuerzt (u.a. groups.html, settings_dhcp.html, account.html, devices/switches/users/credentials.html, zwei grosse Migrations-/DHCP-Kommentarbloecke in app.py) - Kernaussagen erhalten, Redundanz entfernt. Quelltext-Huerde (KEINE echte Sicherheit, nur Abschreckung): Rechtsklick und DevTools-/Quelltext-Shortcuts per JS blockiert. Klar dokumentiert in Kommentar + README, dass der Browser HTML/CSS/JS immer vollstaendig ausliefert und das in Sekunden umgehbar ist - echte Absicherung bleiben ausschliesslich die serverseitigen Rechteprüfungen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
408 lines
16 KiB
JavaScript
408 lines
16 KiB
JavaScript
/* ==========================================================================
|
|
PoE Manager — shared UI behaviour (sidebar, theme, modals, toasts)
|
|
========================================================================== */
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
/* ---------------- Theme ---------------- */
|
|
|
|
const THEME_KEY = "poe-theme";
|
|
|
|
function applyTheme(theme) {
|
|
document.documentElement.setAttribute("data-theme", theme);
|
|
document.querySelectorAll("[data-theme-icon]").forEach((el) => {
|
|
el.innerHTML = theme === "light" ? ICONS.moon : ICONS.sun;
|
|
});
|
|
}
|
|
|
|
function initTheme() {
|
|
const saved = localStorage.getItem(THEME_KEY) ||
|
|
(window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
|
|
applyTheme(saved);
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const current = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
|
|
const next = current === "light" ? "dark" : "light";
|
|
localStorage.setItem(THEME_KEY, next);
|
|
applyTheme(next);
|
|
}
|
|
|
|
const ICONS = {
|
|
sun: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>',
|
|
moon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z"/></svg>',
|
|
};
|
|
|
|
/* ---------------- Sidebar (mobile) ---------------- */
|
|
|
|
function initSidebar() {
|
|
const sidebar = document.querySelector(".sidebar");
|
|
const backdrop = document.querySelector(".sidebar-backdrop");
|
|
const main = document.querySelector(".main");
|
|
const toggles = document.querySelectorAll("[data-sidebar-toggle]");
|
|
if (!sidebar) return;
|
|
|
|
const DESKTOP_BREAKPOINT = 900;
|
|
const COLLAPSE_KEY = "poe-sidebar-collapsed";
|
|
|
|
// Mobile: temporäres Überlagern per Hamburger + Backdrop.
|
|
function open() {
|
|
sidebar.classList.add("open");
|
|
backdrop && backdrop.classList.add("open");
|
|
}
|
|
function close() {
|
|
sidebar.classList.remove("open");
|
|
backdrop && backdrop.classList.remove("open");
|
|
}
|
|
|
|
// Desktop: dauerhaftes Ein-/Ausklappen, über Neuladen hinweg gemerkt.
|
|
function setCollapsed(collapsed) {
|
|
sidebar.classList.toggle("collapsed", collapsed);
|
|
main && main.classList.toggle("sidebar-collapsed", collapsed);
|
|
localStorage.setItem(COLLAPSE_KEY, collapsed ? "1" : "0");
|
|
}
|
|
|
|
if (localStorage.getItem(COLLAPSE_KEY) === "1") setCollapsed(true);
|
|
|
|
toggles.forEach((btn) => btn.addEventListener("click", () => {
|
|
if (window.innerWidth <= DESKTOP_BREAKPOINT) {
|
|
sidebar.classList.contains("open") ? close() : open();
|
|
} else {
|
|
setCollapsed(!sidebar.classList.contains("collapsed"));
|
|
}
|
|
}));
|
|
backdrop && backdrop.addEventListener("click", close);
|
|
document.querySelectorAll(".nav-item").forEach((a) => a.addEventListener("click", close));
|
|
}
|
|
|
|
/* ---------------- Modals ---------------- */
|
|
|
|
function openModal(id) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.classList.add("open");
|
|
document.body.style.overflow = "hidden";
|
|
}
|
|
|
|
function closeModal(el) {
|
|
const overlay = el.closest ? el.closest(".modal-overlay") : el;
|
|
if (!overlay) return;
|
|
overlay.classList.remove("open");
|
|
document.body.style.overflow = "";
|
|
}
|
|
|
|
function initModals() {
|
|
document.querySelectorAll("[data-open-modal]").forEach((trigger) => {
|
|
trigger.addEventListener("click", () => openModal(trigger.getAttribute("data-open-modal")));
|
|
});
|
|
document.querySelectorAll("[data-close-modal]").forEach((btn) => {
|
|
btn.addEventListener("click", () => closeModal(btn));
|
|
});
|
|
document.querySelectorAll(".modal-overlay").forEach((overlay) => {
|
|
overlay.addEventListener("click", (e) => {
|
|
if (e.target === overlay) closeModal(overlay);
|
|
});
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape") {
|
|
document.querySelectorAll(".modal-overlay.open").forEach((o) => closeModal(o));
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------------- Confirm dialog (replaces window.confirm) ---------------- */
|
|
|
|
function ensureConfirmModal() {
|
|
if (document.getElementById("confirm-modal")) return;
|
|
const html = `
|
|
<div class="modal-overlay" id="confirm-modal">
|
|
<div class="modal" style="max-width:380px;">
|
|
<div class="modal-header">
|
|
<h3 id="confirm-title">Bist du sicher?</h3>
|
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p id="confirm-message" class="text-dim"></p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
|
<button type="button" class="btn btn-danger" id="confirm-ok" style="background:var(--danger);color:#fff;">Bestätigen</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
document.body.insertAdjacentHTML("beforeend", html);
|
|
document.querySelectorAll("#confirm-modal [data-close-modal]").forEach((btn) => {
|
|
btn.addEventListener("click", () => closeModal(btn));
|
|
});
|
|
document.getElementById("confirm-modal").addEventListener("click", (e) => {
|
|
if (e.target.id === "confirm-modal") closeModal(e.target);
|
|
});
|
|
}
|
|
|
|
window.confirmAction = function (message, onConfirm, title) {
|
|
ensureConfirmModal();
|
|
document.getElementById("confirm-title").innerText = title || "Bist du sicher?";
|
|
document.getElementById("confirm-message").innerText = message;
|
|
const okBtn = document.getElementById("confirm-ok");
|
|
const freshBtn = okBtn.cloneNode(true);
|
|
okBtn.parentNode.replaceChild(freshBtn, okBtn);
|
|
freshBtn.addEventListener("click", () => {
|
|
closeModal(freshBtn);
|
|
onConfirm();
|
|
});
|
|
openModal("confirm-modal");
|
|
};
|
|
|
|
/* Intercept forms/buttons marked with data-confirm="message" */
|
|
function initConfirmables() {
|
|
document.querySelectorAll("form[data-confirm]").forEach((form) => {
|
|
form.addEventListener("submit", function (e) {
|
|
if (form.dataset.confirmed === "1") return;
|
|
e.preventDefault();
|
|
// Welcher Button hat den Submit ausgelöst? Muss beim erneuten
|
|
// requestSubmit() explizit mitgegeben werden — sonst geht bei
|
|
// Formularen, die ihre ID über name/value des Submit-Buttons
|
|
// transportieren (statt über ein <input type="hidden">), dieses
|
|
// Feld beim Neu-Absenden stillschweigend verloren (requestSubmit()
|
|
// ohne Argument zählt als "kein Button aktiviert").
|
|
const submitter = e.submitter;
|
|
window.confirmAction(form.getAttribute("data-confirm"), () => {
|
|
form.dataset.confirmed = "1";
|
|
form.requestSubmit ? form.requestSubmit(submitter) : form.submit();
|
|
}, form.getAttribute("data-confirm-title"));
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------------- Toasts ---------------- */
|
|
|
|
function ensureToastStack() {
|
|
let stack = document.querySelector(".toast-stack");
|
|
if (!stack) {
|
|
stack = document.createElement("div");
|
|
stack.className = "toast-stack";
|
|
document.body.appendChild(stack);
|
|
}
|
|
return stack;
|
|
}
|
|
|
|
window.showToast = function (message, type) {
|
|
const stack = ensureToastStack();
|
|
const toast = document.createElement("div");
|
|
toast.className = "toast " + (type || "info");
|
|
toast.innerHTML = `<span>${message}</span><span class="toast-close">×</span>`;
|
|
stack.appendChild(toast);
|
|
const remove = () => {
|
|
toast.classList.add("hide");
|
|
setTimeout(() => toast.remove(), 180);
|
|
};
|
|
toast.querySelector(".toast-close").addEventListener("click", remove);
|
|
setTimeout(remove, 5000);
|
|
};
|
|
|
|
function initFlashedMessages() {
|
|
const data = document.getElementById("flashed-data");
|
|
if (!data) return;
|
|
try {
|
|
const messages = JSON.parse(data.textContent);
|
|
messages.forEach(([category, msg]) => {
|
|
const type = category === "danger" ? "danger" : category === "success" ? "success" : "info";
|
|
window.showToast(msg, type);
|
|
});
|
|
} catch (e) { /* noop */ }
|
|
}
|
|
|
|
/* Nav-Gruppen mit Untermenü (z.B. "Logs") auf-/zuklappen — der manuell
|
|
aufgeklappte Zustand bleibt über Seitenwechsel hinweg erhalten
|
|
(localStorage), zusätzlich zur automatischen Aufklappung der Gruppe
|
|
der aktuell aktiven Seite (server-seitig via "expanded"-Klasse). */
|
|
const NAV_EXPANDED_KEY = "poe-nav-expanded";
|
|
|
|
function getExpandedNavGroups() {
|
|
try {
|
|
return new Set(JSON.parse(localStorage.getItem(NAV_EXPANDED_KEY) || "[]"));
|
|
} catch (e) {
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
function saveExpandedNavGroups(set) {
|
|
localStorage.setItem(NAV_EXPANDED_KEY, JSON.stringify(Array.from(set)));
|
|
}
|
|
|
|
function initNavGroups() {
|
|
const expanded = getExpandedNavGroups();
|
|
document.querySelectorAll("[data-nav-group]").forEach((group) => {
|
|
const key = group.dataset.navGroupKey;
|
|
if (key && expanded.has(key)) group.classList.add("expanded");
|
|
});
|
|
document.querySelectorAll("[data-nav-group-toggle]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const group = btn.closest("[data-nav-group]");
|
|
const isExpanded = group.classList.toggle("expanded");
|
|
const key = group.dataset.navGroupKey;
|
|
if (!key) return;
|
|
const set = getExpandedNavGroups();
|
|
if (isExpanded) set.add(key); else set.delete(key);
|
|
saveExpandedNavGroups(set);
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------------- Globaler "Nächste Prüfung"-Timer (Topbar, jede Seite) ---------------- */
|
|
|
|
function initCheckTimer() {
|
|
const pill = document.getElementById("global-timer-pill");
|
|
const timerEl = document.getElementById("global-timer");
|
|
if (!pill || !timerEl) return;
|
|
|
|
// lastRunMs/intervalMs werden bei JEDEM Tick frisch aus dem DOM gelesen
|
|
// (nicht einmalig gecacht) — so kann z.B. das Dashboard nach einem
|
|
// Live-Update (siehe dashboard-tiles.js) oder ein manueller "Jetzt
|
|
// prüfen"-Klick das data-last-run-ms-Attribut aktualisieren und der
|
|
// global laufende Countdown übernimmt das automatisch.
|
|
function update() {
|
|
const lastRunMs = parseInt(pill.dataset.lastRunMs, 10);
|
|
const intervalMs = parseInt(pill.dataset.intervalMs, 10);
|
|
if (!intervalMs) { timerEl.textContent = "--"; return; }
|
|
let remainingMs = intervalMs;
|
|
if (!isNaN(lastRunMs) && lastRunMs > 0) {
|
|
const elapsed = Date.now() - lastRunMs;
|
|
remainingMs = intervalMs - (((elapsed % intervalMs) + intervalMs) % intervalMs);
|
|
}
|
|
timerEl.textContent = Math.max(0, Math.ceil(remainingMs / 1000));
|
|
}
|
|
update();
|
|
setInterval(update, 1000);
|
|
}
|
|
|
|
/* "Jetzt prüfen"-Button neben dem globalen Timer (nur Admins): startet
|
|
rpi-check.service neu (frisches Logfile + sofortiger Durchlauf) und
|
|
meldet das per Custom Event, damit Dashboard/Live-Log sich selbst
|
|
aktualisieren können, ohne dass app.js die Seiteninhalte kennen muss. */
|
|
function initRunCheckNow() {
|
|
const btn = document.getElementById("run-check-now");
|
|
if (!btn) return;
|
|
btn.addEventListener("click", () => {
|
|
btn.disabled = true;
|
|
btn.classList.add("spinning");
|
|
fetch("/check/run-now", { method: "POST" })
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
if (!data.success) {
|
|
window.showToast(data.msg || "Fehler beim Starten der Prüfung.", "danger");
|
|
btn.disabled = false;
|
|
btn.classList.remove("spinning");
|
|
return;
|
|
}
|
|
window.showToast(data.msg || "Prüfung gestartet.", "success");
|
|
// Der Service braucht einen Moment, um neu zu starten und den
|
|
// ersten Durchlauf ins frische Logfile zu schreiben.
|
|
setTimeout(() => {
|
|
document.dispatchEvent(new CustomEvent("poe:check-triggered"));
|
|
btn.disabled = false;
|
|
btn.classList.remove("spinning");
|
|
}, 1500);
|
|
})
|
|
.catch(() => {
|
|
window.showToast("Fehler beim Starten der Prüfung.", "danger");
|
|
btn.disabled = false;
|
|
btn.classList.remove("spinning");
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------------- Sortierbare Tabellen ---------------- */
|
|
/*
|
|
* Klick auf <th data-sort-key="..."> sortiert die Tabelle. Zwei Modi:
|
|
* - Normale Tabellen: sortiert <tr>-Zeilen innerhalb des einzigen <tbody>
|
|
* anhand von data-sort-<key> auf der jeweiligen <tr>.
|
|
* - Akkordeon-Tabellen (mehrere <tbody>, z.B. Gruppen mit Detail-Zeile):
|
|
* sortiert ganze <tbody>-Blöcke anhand von data-sort-<key> auf dem
|
|
* jeweiligen <tbody>, damit Haupt- und Detail-Zeile zusammenbleiben.
|
|
* <tbody data-sort-pinned> (z.B. die virtuelle "Admin"-Zeile) bleibt
|
|
* dabei immer an ihrer Position.
|
|
*/
|
|
function compareSortValues(a, b, asc) {
|
|
a = (a === null || a === undefined) ? "" : String(a);
|
|
b = (b === null || b === undefined) ? "" : String(b);
|
|
const na = parseFloat(a), nb = parseFloat(b);
|
|
const bothNumeric = a !== "" && b !== "" && !isNaN(na) && !isNaN(nb);
|
|
const cmp = bothNumeric ? (na - nb) : a.toLowerCase().localeCompare(b.toLowerCase(), "de");
|
|
return asc ? cmp : -cmp;
|
|
}
|
|
|
|
function sortTable(table, th) {
|
|
const key = th.dataset.sortKey;
|
|
const asc = th.dataset.sortDir !== "asc";
|
|
table.querySelectorAll("thead th[data-sort-key]").forEach((h) => {
|
|
delete h.dataset.sortDir;
|
|
h.classList.remove("sort-asc", "sort-desc");
|
|
});
|
|
th.dataset.sortDir = asc ? "asc" : "desc";
|
|
th.classList.add(asc ? "sort-asc" : "sort-desc");
|
|
|
|
if (table.tBodies.length > 1) {
|
|
const movable = Array.prototype.filter.call(table.tBodies, (tb) => !tb.hasAttribute("data-sort-pinned"));
|
|
movable.sort((a, b) => compareSortValues(
|
|
a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc
|
|
));
|
|
movable.forEach((tb) => table.appendChild(tb));
|
|
} else {
|
|
const tbody = table.tBodies[0];
|
|
const rows = Array.prototype.filter.call(tbody.rows, (r) => !r.classList.contains("empty-row"));
|
|
rows.sort((a, b) => compareSortValues(
|
|
a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc
|
|
));
|
|
rows.forEach((r) => tbody.appendChild(r));
|
|
}
|
|
}
|
|
|
|
function initSortableTables() {
|
|
document.querySelectorAll("table[data-sortable] thead th[data-sort-key]").forEach((th) => {
|
|
th.classList.add("sortable-col");
|
|
th.addEventListener("click", () => sortTable(th.closest("table"), th));
|
|
});
|
|
}
|
|
|
|
/* ---------------- Quelltext-Hürde ----------------
|
|
Blockiert Rechtsklick-Menü und die üblichen DevTools-/Quelltext-
|
|
Shortcuts. WICHTIG: das ist eine Hürde für gelegentliches Kopieren,
|
|
KEIN echter Schutz — jeder Browser liefert HTML/CSS/JS immer
|
|
vollständig aus, sonst könnte er die Seite nicht darstellen. Wer
|
|
wirklich an den Quelltext will, umgeht das in Sekunden (URL-Leiste,
|
|
externer HTTP-Client, DevTools per Menü statt Shortcut). Echte
|
|
Sicherheit kommt ausschließlich von den serverseitigen Rechteprüfungen
|
|
in app.py, nicht davon, das Nachsehen im Browser zu erschweren. */
|
|
function initSourceProtection() {
|
|
document.addEventListener("contextmenu", (e) => e.preventDefault());
|
|
document.addEventListener("keydown", (e) => {
|
|
const key = e.key.toUpperCase();
|
|
const blocked =
|
|
key === "F12" ||
|
|
(e.ctrlKey && e.shiftKey && ["I", "J", "C"].includes(key)) ||
|
|
(e.ctrlKey && key === "U");
|
|
if (blocked) e.preventDefault();
|
|
});
|
|
}
|
|
|
|
/* ---------------- Init ---------------- */
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
initTheme();
|
|
initSidebar();
|
|
initModals();
|
|
initConfirmables();
|
|
initFlashedMessages();
|
|
initNavGroups();
|
|
initCheckTimer();
|
|
initRunCheckNow();
|
|
initSortableTables();
|
|
initSourceProtection();
|
|
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
|
});
|
|
|
|
window.PoeUI = { openModal, closeModal };
|
|
})();
|