- /logs und /get_log liefern nur noch die letzten 300 Zeilen (_tail_lines, effizientes Rueckwaerts-Lesen ohne komplette Datei einzulesen) statt der kompletten live.log -- behebt Ladezeit-Probleme auf groesseren Umgebungen. - Neuer Endpoint /logs/live/raw liefert das komplette Live-Log fuer ein Popup im Stil des bestehenden 'Ungespeicherte Aenderungen'-Modals (window.openRawLogModal in app.js, lazy nachgeladen erst bei Klick). - Neue Unterseite 'Verlauf' (/logs/history, /logs/history/raw) fuer aeltere, von logrotate rotierte Kopien des Live-Logs (live.log.1, .2, ...), auswaehlbar per Dropdown, ebenfalls mit 300-Zeilen-Tail + RAW-Popup. - Eigenes Recht logs_history.view (Kind von logs_group, direkt unter Live einsortiert) statt an logs_live.view gekoppelt -- Live-Status sehen und durch alte Logs blaettern sind bewusst getrennte Rechte. - Dateiname-Validierung gegen Path-Traversal in _resolve_log_history_file (striktes Regex + Verzeichnis-Check + Existenzpruefung). - VERSION 1.0.4 -> 1.0.5. Live getestet auf Testsystem-Update-Restart (192.168.82.51): Tail-Anzeige, RAW-Popup, Verlauf-Auswahl inkl. echter live.log.1, Permission-Matrix (Gruppen-Seite) sowie Rechtetrennung (Nutzer mit nur logs_live.view sieht /logs, aber weder Verlauf-Link noch Zugriff auf /logs/history bzw. /logs/history/raw) und Path-Traversal-Abwehr allesamt verifiziert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
645 lines
27 KiB
JavaScript
645 lines
27 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;
|
|
});
|
|
// Favicon + Sidebar-Logo folgen demselben Theme wie der Rest der App
|
|
// (auch bei manuellem Umschalten, nicht nur bei System-Präferenz) --
|
|
// "dunkel"/"hell" bezeichnen hier die für den jeweiligen Modus gedachte
|
|
// Logo-Variante (helle Farben für Dark Mode, dunkle für Light Mode).
|
|
const suffix = theme === "light" ? "light" : "dark";
|
|
const favicon = document.getElementById("app-favicon");
|
|
if (favicon) favicon.href = "/static/images/icon-" + suffix + ".svg";
|
|
const sidebarLogo = document.getElementById("sidebar-logo");
|
|
if (sidebarLogo) sidebarLogo.src = "/static/images/logo-" + suffix + ".svg";
|
|
const loginBgLogo = document.getElementById("login-bg-logo");
|
|
if (loginBgLogo) loginBgLogo.src = "/static/images/logo-" + suffix + "-subline.svg";
|
|
}
|
|
|
|
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");
|
|
};
|
|
|
|
/* ---------------- Ja/Nein-Dialog für ungespeicherte Änderungen ---------------- */
|
|
/* Eigener Dialog statt confirmAction (dort ist "Abbrechen" = beim Bleiben,
|
|
hier gibt es bewusst nur die zwei angefragten Optionen: speichern oder
|
|
verwerfen — beide verlassen die Seite, nur "Abbrechen"/Escape/Backdrop
|
|
bricht das Verlassen selbst ab und lässt die Seite unverändert offen. */
|
|
function ensureSaveDiscardModal() {
|
|
if (document.getElementById("save-discard-modal")) return;
|
|
const html = `
|
|
<div class="modal-overlay" id="save-discard-modal">
|
|
<div class="modal" style="max-width:420px;">
|
|
<div class="modal-header">
|
|
<h3>Ungespeicherte Änderungen</h3>
|
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="text-dim" id="save-discard-message"></p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" id="save-discard-no">Nein, verwerfen</button>
|
|
<button type="button" class="btn btn-primary" id="save-discard-yes">Ja, speichern</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
document.body.insertAdjacentHTML("beforeend", html);
|
|
document.querySelectorAll("#save-discard-modal [data-close-modal]").forEach((btn) => {
|
|
btn.addEventListener("click", () => closeModal(btn));
|
|
});
|
|
document.getElementById("save-discard-modal").addEventListener("click", (e) => {
|
|
if (e.target.id === "save-discard-modal") closeModal(e.target);
|
|
});
|
|
}
|
|
|
|
window.confirmSaveDiscard = function (message, onSave, onDiscard) {
|
|
ensureSaveDiscardModal();
|
|
document.getElementById("save-discard-message").innerText = message;
|
|
const yesBtn = document.getElementById("save-discard-yes");
|
|
const noBtn = document.getElementById("save-discard-no");
|
|
const freshYes = yesBtn.cloneNode(true);
|
|
yesBtn.parentNode.replaceChild(freshYes, yesBtn);
|
|
const freshNo = noBtn.cloneNode(true);
|
|
noBtn.parentNode.replaceChild(freshNo, noBtn);
|
|
freshYes.addEventListener("click", () => { closeModal(freshYes); onSave(); });
|
|
freshNo.addEventListener("click", () => { closeModal(freshNo); onDiscard(); });
|
|
openModal("save-discard-modal");
|
|
};
|
|
|
|
/* ---------------- RAW-Log-Popup (Live-Log & Verlauf) ---------------- */
|
|
/* Gleiches Modal-Grundgerüst wie der Ja/Nein-Dialog für ungespeicherte
|
|
Änderungen (modal-overlay/modal-header/modal-body/modal-footer), nur
|
|
breiter/höher und mit lazy nachgeladenem Inhalt -- der komplette
|
|
Log-Inhalt wird bewusst erst beim tatsächlichen Öffnen per fetch
|
|
nachgeladen, nicht schon beim Seitenaufbau, damit genau das Problem
|
|
(unnötig große Ladezeiten) nicht durch das Popup selbst zurückkommt. */
|
|
function ensureRawLogModal() {
|
|
if (document.getElementById("raw-log-modal")) return;
|
|
const html = `
|
|
<div class="modal-overlay" id="raw-log-modal">
|
|
<div class="modal" style="max-width:min(1100px, 92vw); height:85vh;">
|
|
<div class="modal-header">
|
|
<h3 id="raw-log-modal-title">Komplettes Log (RAW)</h3>
|
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
|
</div>
|
|
<div class="modal-body" style="padding:0; display:flex; flex-direction:column; flex:1; min-height:0;">
|
|
<pre id="raw-log-modal-content" class="raw-log-content"></pre>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<span class="text-faint mono" id="raw-log-modal-meta" style="margin-right:auto; font-size:11.5px;"></span>
|
|
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
document.body.insertAdjacentHTML("beforeend", html);
|
|
document.querySelectorAll("#raw-log-modal [data-close-modal]").forEach((btn) => {
|
|
btn.addEventListener("click", () => closeModal(btn));
|
|
});
|
|
document.getElementById("raw-log-modal").addEventListener("click", (e) => {
|
|
if (e.target.id === "raw-log-modal") closeModal(e.target);
|
|
});
|
|
}
|
|
|
|
window.openRawLogModal = function (url, title) {
|
|
if (!url) return;
|
|
ensureRawLogModal();
|
|
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
|
|
const content = document.getElementById("raw-log-modal-content");
|
|
const meta = document.getElementById("raw-log-modal-meta");
|
|
content.textContent = "Lade …";
|
|
meta.textContent = "";
|
|
openModal("raw-log-modal");
|
|
fetch(url)
|
|
.then((r) => {
|
|
const logName = r.headers.get("X-Log-Name");
|
|
if (logName) meta.textContent = logName;
|
|
return r.text();
|
|
})
|
|
.then((text) => { content.textContent = text; })
|
|
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
|
|
};
|
|
|
|
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
|
|
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
|
|
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
|
|
Toggle-Buttons) seit dem Laden der Seite verändert wurde, und fragt vor
|
|
dem Verlassen (Klick auf einen Link, z.B. in der Navigation) per Ja/Nein,
|
|
ob zuerst gespeichert werden soll — statt Änderungen stillschweigend zu
|
|
verwerfen. "Ja" sendet das Formular ganz normal ab (echter POST, kein
|
|
AJAX-Nachbau nötig) und merkt sich das eigentliche Ziel in
|
|
sessionStorage, um nach dem Speichern automatisch dorthin
|
|
weiterzuleiten, statt dass der Link ein zweites Mal angeklickt werden
|
|
muss. Für Browser-eigene Navigation (Tab schließen, Reload, Adresszeile)
|
|
gibt es zusätzlich beforeunload — dort erlaubt der Browser aus
|
|
Sicherheitsgründen aber nur eine generische Warnung, kein eigenes
|
|
Ja/Nein/Speichern-Dialogfeld. */
|
|
const PENDING_NAV_KEY = "poe-pending-nav-after-save";
|
|
|
|
function serializeForm(form) {
|
|
return new URLSearchParams(new FormData(form)).toString();
|
|
}
|
|
|
|
function initUnsavedChangesGuard() {
|
|
const pendingNav = sessionStorage.getItem(PENDING_NAV_KEY);
|
|
if (pendingNav) {
|
|
sessionStorage.removeItem(PENDING_NAV_KEY);
|
|
window.location.href = pendingNav;
|
|
return;
|
|
}
|
|
|
|
const trackable = [];
|
|
document.querySelectorAll("form[method='post' i]").forEach((form) => {
|
|
if (form.hasAttribute("data-no-unsaved-guard")) return;
|
|
const fields = form.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]), select, textarea");
|
|
if (!fields.length) return; // reine Aktions-Formulare (Löschen/Toggle) ohne Eingabefeld
|
|
trackable.push({ form, initial: serializeForm(form) });
|
|
});
|
|
if (!trackable.length) return;
|
|
|
|
// Verhindert die native Browser-Warnung (beforeunload), wenn die Seite
|
|
// ohnehin schon bewusst verlassen wird — entweder weil eines der
|
|
// beobachteten Formulare ganz normal abgeschickt wurde (eigener
|
|
// "Speichern"/"Anlegen"-Button auf der Seite) oder weil unser eigener
|
|
// Ja/Nein-Dialog die Navigation ausgelöst hat. Ohne das würde beim
|
|
// normalen Speichern zusätzlich zum eigentlichen Erfolg immer noch die
|
|
// generische Browser-Meldung aufpoppen — die soll ausschließlich dann
|
|
// erscheinen, wenn tatsächlich UNGEFRAGT navigiert wird (Tab schließen,
|
|
// Reload, Adresszeile), nicht bei einem bewussten Speichern.
|
|
let navigatingAway = false;
|
|
trackable.forEach(({ form }) => {
|
|
form.addEventListener("submit", () => { navigatingAway = true; });
|
|
});
|
|
|
|
function dirtyForm() {
|
|
return trackable.find(({ form, initial }) => serializeForm(form) !== initial) || null;
|
|
}
|
|
|
|
document.addEventListener("click", (e) => {
|
|
const link = e.target.closest("a[href]");
|
|
if (!link || link.target === "_blank" || link.href.startsWith("javascript:") || link.hasAttribute("data-no-unsaved-guard")) return;
|
|
const dirty = dirtyForm();
|
|
if (!dirty) return;
|
|
e.preventDefault();
|
|
const href = link.href;
|
|
window.confirmSaveDiscard(
|
|
"Es gibt ungespeicherte Änderungen auf dieser Seite. Vor dem Verlassen speichern?",
|
|
() => {
|
|
navigatingAway = true;
|
|
sessionStorage.setItem(PENDING_NAV_KEY, href);
|
|
dirty.form.requestSubmit ? dirty.form.requestSubmit() : dirty.form.submit();
|
|
},
|
|
() => {
|
|
navigatingAway = true;
|
|
window.location.href = href;
|
|
}
|
|
);
|
|
});
|
|
|
|
window.addEventListener("beforeunload", (e) => {
|
|
if (!navigatingAway && dirtyForm()) {
|
|
e.preventDefault();
|
|
e.returnValue = "";
|
|
}
|
|
});
|
|
}
|
|
|
|
/* 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);
|
|
};
|
|
|
|
/* Zeigt ein per Dropdown ausgewähltes, bis dahin verstecktes DHCP-Options-
|
|
Feld (siehe settings_dhcp.html, "+ Option hinzufügen"-Auswahl bei
|
|
Reservierungen) — so sind nur tatsächlich genutzte Options-Felder
|
|
sichtbar, statt jede einzeln immer als leeres Eingabefeld anzuzeigen.
|
|
Verschiebt das Feld dafür direkt unter das Dropdown (in den Container
|
|
mit der Klasse "poe-added-options", der im Markup direkt danach steht)
|
|
statt es nur an seiner ursprünglichen Stelle einzublenden — bei bis zu
|
|
~60 möglichen Optionen läge diese sonst weit unterhalb des sichtbaren
|
|
Modal-Ausschnitts und es wirkte so, als wäre gar nichts passiert. Jede
|
|
weitere Auswahl hängt ihr Feld unten an, sodass alle bereits
|
|
hinzugefügten Options sichtbar aufgereiht bleiben. */
|
|
window.poeShowOptionField = function (select) {
|
|
const id = select.value;
|
|
if (id) {
|
|
const field = document.getElementById(id);
|
|
const container = select.nextElementSibling;
|
|
if (field && container && container.classList.contains("poe-added-options")) {
|
|
container.appendChild(field);
|
|
field.style.display = "";
|
|
const input = field.querySelector("input");
|
|
if (input) input.focus();
|
|
}
|
|
select.value = "";
|
|
}
|
|
};
|
|
|
|
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
|
|
tesm-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");
|
|
document.dispatchEvent(new CustomEvent("poe:check-triggered"));
|
|
// Topbar-Countdown auf JEDER Seite aktualisieren, nicht nur auf
|
|
// dem Dashboard — vorher lief er auf allen anderen Seiten mit dem
|
|
// alten Zeitpunkt weiter, obwohl im Hintergrund längst neu geprüft
|
|
// wurde (die Prüfung lief trotzdem korrekt, nur sichtbar tat sich
|
|
// nichts). Der Server liefert den frischen Zeitpunkt direkt in
|
|
// dieser Antwort mit (siehe run_check_now) statt eines zweiten
|
|
// Requests dafür.
|
|
const pill = document.getElementById("global-timer-pill");
|
|
if (pill && data.last_run_epoch_ms) {
|
|
pill.dataset.lastRunMs = data.last_run_epoch_ms;
|
|
}
|
|
btn.disabled = false;
|
|
btn.classList.remove("spinning");
|
|
})
|
|
.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));
|
|
});
|
|
}
|
|
|
|
/* ---------------- Init ---------------- */
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
initTheme();
|
|
initSidebar();
|
|
initModals();
|
|
initConfirmables();
|
|
initFlashedMessages();
|
|
initNavGroups();
|
|
initCheckTimer();
|
|
initRunCheckNow();
|
|
initLeaseCountdowns();
|
|
initUnsavedChangesGuard();
|
|
initSortableTables();
|
|
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
|
});
|
|
|
|
/* ---------------- DHCP-Lease-Countdown (settings_dhcp.html) ---------------- */
|
|
/* Ergänzt die absolute (UTC-)Ablaufzeit einer Lease um einen live tickenden
|
|
Countdown — bei den kurzen Standard-Lease-Zeiten (600s) wirkt eine reine
|
|
Absolutzeit sonst schnell "schon abgelaufen", sobald etwas Zeit vergangen
|
|
ist, bis man die (nicht selbst aktualisierende) Seite tatsächlich
|
|
anschaut. Gleiches Tick-Prinzip wie initCheckTimer oben. */
|
|
function formatLeaseCountdown(remainingMs) {
|
|
const abs = Math.abs(Math.round(remainingMs / 1000));
|
|
const m = Math.floor(abs / 60);
|
|
const s = abs % 60;
|
|
const duration = m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
return remainingMs >= 0 ? `läuft ab in ${duration}` : `abgelaufen vor ${duration}`;
|
|
}
|
|
|
|
function initLeaseCountdowns() {
|
|
const els = document.querySelectorAll(".lease-countdown");
|
|
if (!els.length) return;
|
|
function tick() {
|
|
const now = Date.now();
|
|
els.forEach((el) => {
|
|
const expireMs = parseInt(el.dataset.expireMs, 10);
|
|
if (isNaN(expireMs)) return;
|
|
el.textContent = formatLeaseCountdown(expireMs - now);
|
|
el.classList.toggle("text-danger", expireMs < now);
|
|
});
|
|
}
|
|
tick();
|
|
setInterval(tick, 1000);
|
|
}
|
|
|
|
window.PoeUI = { openModal, closeModal };
|
|
})();
|