Bugfix (von dir gemeldet, live auf Port 5000 gefunden): - initConfirmables() in app.js rief beim bestätigten Absenden form.requestSubmit() OHNE den ursprünglichen Submitter auf. Laut Spec wird dann KEIN Submit-Button-Name/Value mitgeschickt. users.html trug die Benutzer-ID als name/value des Löschen-Buttons selbst (statt eines verstecken Inputs wie überall sonst) — dadurch kam "delete_user" nie im Request an, der Klick tat scheinbar nichts. Fix: e.submitter beim ersten Klick merken und an requestSubmit(submitter) weiterreichen (behebt es grundsätzlich für alle Formulare dieser Art); zusätzlich users.html auf das robustere hidden-input-Muster umgestellt wie credentials/devices/groups. Mit echtem Playwright-Browserklick (nicht nur curl) verifiziert. Gruppen-Rechtetabelle, mehrere Korrekturrunden nach deinem Mockup: - Bereichsname + Kill-Switch-Kästchen jetzt eng nebeneinander in der Tabellen-Kopfzeile (nicht mehr an den Rand gestellt), R/W/E/D-Spalten in derselben Zeile statt einer zweiten. - Nur die von einem Bereich tatsächlich genutzten Spalten werden gezeigt (GROUP_ROW_TYPES) — Logs zeigt nur R, Einstellungen nur R/W/E, nur Geräte zeigt zusätzlich D. - Bugfix: D-Spalte bei Geräte verschwand durch zu schmale Flex-Breite der Spalte im Overflow, obwohl serverseitig korrekt gerendert — behoben durch flex:0 0 auto statt fester Breite. - Checkbox-Zentrierung robust per display:block+margin:auto statt sich auf native Checkbox-Ränder zu verlassen (waren leicht asymmetrisch). - Unterpunkte deutlich weiter eingerückt (34px) für klare Hierarchie. - Sichtbares Ausgrauen (nicht nur natives disabled) der Unterpunkte, solange der Bereich-Kill-Switch aus ist (JS setzt .permission-locked). - Legende jetzt UNTER der Rechtetabelle statt darüber. - Rechte lassen sich jetzt direkt beim Anlegen einer neuen Gruppe setzen (Modal zeigt dieselbe Rechtetabelle, add_group-Route verarbeitet mitgesendete permissions[] im selben Request) statt sie erst danach über die Gruppentabelle nachzupflegen. Alles inkl. Erstellen-mit-Rechten und Bearbeiten-Regression live per Playwright verifiziert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
386 lines
15 KiB
JavaScript
386 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");
|
|
// 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));
|
|
});
|
|
}
|
|
|
|
/* ---------------- 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 };
|
|
})();
|