Files
tesm/srv/poe_manager/static/js/app.js
T
alientimandClaude Sonnet 5 4d7433e832 Fix: SSH-Terminal-Login (No auth methods), kaputtes DOM in Bearbeiten-Modals
Quelltext-Huerde wieder entfernt (auf Wunsch) - siehe vorherigen Commit,
hier nur der Revert von initSourceProtection() und der README-Erwaehnung.

SSH-Terminal-Login war komplett kaputt (von dir gemeldet + Screenshot):
- Root Cause: das Init-Payload vom Browser enthielt nie ein Passwort,
  SSHClient.connect() bekam also weder Passwort noch Key noch Agent und
  scheiterte sofort mit "No authentication methods available" - noch
  bevor ueberhaupt eine interaktive Passwortabfrage moeglich gewesen
  waere (die High-Level-API erledigt Host-Key-Pruefung UND
  Authentifizierung in einem blockierenden Aufruf).
- Fix: Umstieg auf die Low-Level paramiko.Transport-API. Nach
  Host-Key-Bestaetigung wird aktiv erfragt, welche Auth-Methoden der
  Server anbietet (auth_none), und bei Bedarf interaktiv ueber das
  Browser-Terminal nach Passwort/keyboard-interactive-Prompts gefragt
  -- genau das Verhalten, das der bestehende Hinweistext im Modal schon
  immer versprach, aber nie tatsaechlich implementiert war.
  Host-Key-Verifikation dabei manuell nachgebaut (_verify_host_key_interactive)
  inkl. hartem Ablehnen bei GEAENDERTEM (nicht nur unbekanntem) Host-Key,
  wie ein echtes ssh-CLI bei einer moeglichen MITM-Situation.
- Waehrend der Live-Verifikation gegen ein echtes Geraet zwei weitere
  Bugs gefunden und gefixt: ws.receive() wirft in diesem Setup
  ConnectionClosed statt None zurueckzugeben (crashte
  _terminal_read_line unbehandelt -> "Invalid frame header" beim
  Client); _send_and_close() crashte ebenso, wenn der Client bereits weg
  war. Beide jetzt defensiv abgefangen.
- Live gegen ein echtes Zielgeraet verifiziert (Host-Key-Bestaetigung,
  Passwort-Prompt, erfolgreicher Login) sowie manuell von dir bestaetigt.

Kaputtes DOM in zwei Bearbeiten-Modals (von dir gemeldet: "Verbindung
testen" oeffnete beim Switch bearbeiten kein Fenster, obwohl es beim
Neuanlegen funktionierte):
- Root Cause: <div class="modal-overlay">...</div> stand direkt in
  <tbody> (nur <tr> ist dort gueltig). Browser "foster-parenten"
  ungueltigen Tbody-Inhalt aus der Tabelle heraus und zerreissen dabei
  teils die Eltern-Kind-Beziehung zwischen <form> und seinen Buttons --
  this.closest("form") lieferte dadurch null statt des Formulars.
  Betroffen: editSwitchModal (switches.html), deviceOptionsModal
  (settings_dhcp.html). Fix: beide Modal-Bloecke aus der Tabelle heraus
  in eine eigene Schleife direkt danach verschoben (gleiches Muster wie
  die bereits korrekten Neuanlegen-Modals).
- Per DOM-Inspektion verifiziert: this.closest("form") lieferte vorher
  null, danach das korrekte Formular fuer alle Zeilen; End-to-End-Test
  bestaetigt, dass sich das Terminal-Modal jetzt oeffnet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 12:00:22 +02:00

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