Files
tesm/srv/tesm/static/js/app.js
T
alientimandClaude Sonnet 5 99d4d3cd01 Live-Log: 300-Zeilen-Tail, RAW-Popup und Log-Verlauf (History) mit eigenem Recht
- /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>
2026-08-22 10:53:56 +02:00

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>&times;</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>&times;</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>&times;</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">&times;</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 };
})();