Der Klick loeste den Neustart von rpi-check.service und damit einen echten, sofortigen Pruef-Durchlauf immer korrekt aus -- nur die Topbar-Anzeige 'Naechste Pruefung in Xs' wurde ausserhalb des Dashboards nie aktualisiert, da nur index.html einen Listener fuer das poe:check-triggered-Event registrierte. Auf allen anderen Seiten (DHCP, Switches, Settings, ...) lief der Countdown deshalb sichtbar mit dem alten Zeitpunkt weiter, obwohl im Hintergrund laengst neu geprueft wurde. Fix (klein gehalten, kein neuer Endpunkt): run_check_now() wartet kurz auf den ersten Log-Eintrag des frisch gestarteten Dienstes und liefert den neuen Zeitpunkt direkt in seiner bestehenden JSON-Antwort mit. app.js schreibt diesen Wert danach unabhaengig von der aktuellen Seite ins global-timer-pill-Element -- der laufende Countdown liest seine Werte ohnehin bei jedem Tick frisch aus dem DOM. Live auf der Test-VM verifiziert (mehrere echte Durchlaeufe ueber /check/run-now, frischer last_run_epoch_ms in der Antwort, keine Fehler im journalctl-Log).
393 lines
15 KiB
JavaScript
393 lines
15 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");
|
|
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();
|
|
initSortableTables();
|
|
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
|
});
|
|
|
|
window.PoeUI = { openModal, closeModal };
|
|
})();
|