Erklärende Kommentare aus dem Code entfernt, in KNOWLEDGE_BASE.md überführt

Betrifft app.py, create_db.py, poe.sh sowie alle HTML-Templates mit
Kommentaren (Jinja {# #}, HTML <!-- -->, eingebettetes JavaScript //).
Docstrings bleiben unangetastet -- nur reine Kommentarzeilen/-fragmente
wurden entfernt, damit der Code selbst schlank bleibt, ohne die
dahinterliegende Begründung zu verlieren.

Umsetzung:
- app.py/create_db.py: per Pythons eigenem tokenize-Modul entfernt (sicher
  gegen '#' innerhalb von String-Literalen, Shebang-Zeile ausgenommen).
- poe.sh: manuell, da einzelne expect-Prompt-Strings ('expect "#"')
  ein literales '#' als Teil des Strings enthalten, kein Kommentar.
- Templates: {# #}/<!-- --> per Regex (eindeutige Delimiter, anschließend
  mit Jinja2s eigenem Parser validiert), echte // -JS-Kommentare manuell
  identifiziert (zwei Fundstellen mit '//' waren tatsächlich Code --
   als Protocol-relative-URL-Template-
  String -- und wurden bewusst nicht angefasst).

Verifiziert: alle Python-/Bash-/Jinja-Dateien syntaktisch weiterhin
gültig, live auf der Testbox deployt und alle 14 Kernrouten mit 200
bestätigt, keine Fehler im App-Log.
This commit is contained in:
2026-08-14 12:05:39 +02:00
parent bc360a0b7f
commit 33753e4f41
16 changed files with 551 additions and 1191 deletions
+11 -901
View File
File diff suppressed because it is too large Load Diff
-47
View File
@@ -20,8 +20,6 @@ DB_PATH = os.environ.get("TESM_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Zugangsdaten (wiederverwendbare SSH-Logins, mehrere Switche können sich
# dieselben teilen; Passwort wird verschlüsselt gespeichert)
c.execute("""
CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -33,8 +31,6 @@ CREATE TABLE IF NOT EXISTS credentials (
);
""")
# Switches (Aruba-Switche) — referenzieren ihre Zugangsdaten statt sie
# direkt zu speichern
c.execute("""
CREATE TABLE IF NOT EXISTS switches (
hostname TEXT PRIMARY KEY,
@@ -48,7 +44,6 @@ CREATE TABLE IF NOT EXISTS switches (
);
""")
# Devices
c.execute("""
CREATE TABLE IF NOT EXISTS devices (
mac TEXT PRIMARY KEY,
@@ -66,7 +61,6 @@ CREATE TABLE IF NOT EXISTS devices (
);
""")
# Benutzer
c.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -83,9 +77,6 @@ CREATE TABLE IF NOT EXISTS users (
);
""")
# Gruppen (Rechtesystem) — Admins besitzen ohnehin alle Rechte; Gruppen
# dienen dazu, normalen Benutzern gezielt einzelne Verwaltungsrechte für
# Devices/Switches zu geben, ohne sie zu Admins zu machen.
c.execute("""
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -96,7 +87,6 @@ CREATE TABLE IF NOT EXISTS groups (
);
""")
# Welche Berechtigungen eine Gruppe gewährt (siehe app.py: PERMISSIONS)
c.execute("""
CREATE TABLE IF NOT EXISTS group_permissions (
group_id INTEGER NOT NULL,
@@ -106,8 +96,6 @@ CREATE TABLE IF NOT EXISTS group_permissions (
);
""")
# Gruppenmitgliedschaft (ein Benutzer kann in mehreren Gruppen sein;
# effektive Rechte = Vereinigung aller Gruppen)
c.execute("""
CREATE TABLE IF NOT EXISTS user_groups (
user_id INTEGER NOT NULL,
@@ -118,7 +106,6 @@ CREATE TABLE IF NOT EXISTS user_groups (
);
""")
# Settings (z.B. Prüfintervall)
c.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@@ -126,11 +113,6 @@ CREATE TABLE IF NOT EXISTS settings (
);
""")
# DHCP: eigene Options-Definitionen (analog "Vordefinierte Optionen und
# Werte" bei einem Windows-DHCP-Server) — Code/Name/Typ/Beschreibung.
# is_standard markiert die vorbefüllten Standard-Optionen (Router/DNS/…) --
# die können vom Admin nicht gelöscht werden, nur ihr Wert geleert (die
# tatsächliche Vorbefüllung übernimmt app.py: _ensure_schema()).
c.execute("""
CREATE TABLE IF NOT EXISTS dhcp_option_defs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -142,12 +124,6 @@ CREATE TABLE IF NOT EXISTS dhcp_option_defs (
);
""")
# DHCP: Werte je Option — device_mac='' bedeutet global (gilt für den
# ganzen Scope), ein gesetzter device_mac überschreibt den globalen Wert
# für genau dieses Gerät. Bewusst '' statt NULL für den globalen Fall,
# da SQLite NULL in UNIQUE-Constraints nicht als gleich behandelt (zwei
# NULL-Zeilen wären sonst kein Konflikt) — mit '' funktioniert ON CONFLICT
# zuverlässig für den Upsert des globalen Werts.
c.execute("""
CREATE TABLE IF NOT EXISTS dhcp_option_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -159,11 +135,6 @@ CREATE TABLE IF NOT EXISTS dhcp_option_values (
);
""")
# DHCP: konfigurierte Subnetze (Interface + Range + optionales Gateway/DNS)
# -- ein Host kann mehrere Subnetze gleichzeitig bedienen, je eines pro
# Interface/Netz. sort_order steuert die Anzeige-/Verarbeitungsreihenfolge,
# enabled erlaubt ein Subnetz vorübergehend aus der geschriebenen
# Konfiguration auszuschließen, ohne es zu löschen.
c.execute("""
CREATE TABLE IF NOT EXISTS dhcp_subnets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -177,9 +148,6 @@ CREATE TABLE IF NOT EXISTS dhcp_subnets (
);
""")
# DHCP: manuelle Reservierungen für Geräte OHNE eigenen Client-Datensatz
# (Geräte mit MAC+IP in der devices-Tabelle bekommen ihre Reservierung
# automatisch, siehe _dhcp_reservation_candidates in app.py).
c.execute("""
CREATE TABLE IF NOT EXISTS dhcp_manual_reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -189,11 +157,6 @@ CREATE TABLE IF NOT EXISTS dhcp_manual_reservations (
);
""")
# Service-Accounts (aktuell nur das LDAP-Bind-Konto) -- bewusst NICHT in der
# users-Tabelle (kein echter, einloggbarer App-Benutzer) und NICHT in der
# credentials-Tabelle (die ist für Switch-/Geräte-Zugangsdaten, ein anderer
# Anwendungsfall) -- eine eigene, kleine Tabelle. "purpose" ist UNIQUE, ein
# neu gespeichertes Konto für denselben Zweck ersetzt das alte.
c.execute("""
CREATE TABLE IF NOT EXISTS service_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -203,9 +166,6 @@ CREATE TABLE IF NOT EXISTS service_accounts (
);
""")
# AD-Gruppe -> App-Rechtegruppe: mehrere AD-Gruppen können jeweils einer
# App-Gruppe zugeordnet werden (additiv, siehe _ldap_resolve_app_groups in
# app.py) -- passt keine dieser Gruppen, greift die Standardgruppe.
c.execute("""
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -216,8 +176,6 @@ CREATE TABLE IF NOT EXISTS ldap_group_mappings (
);
""")
# Änderungslog (Audit-Log) — wer hat wann was geändert. Bewusst OHNE
# PoE-Neustarts (die stehen im Live-Log von poe.sh).
c.execute("""
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -229,14 +187,9 @@ CREATE TABLE IF NOT EXISTS audit_log (
);
""")
# Standard-Setting: Prüfintervall 5 Minuten (interval = Minuten, check_interval = Sekunden)
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5"))
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("check_interval", "300"))
# Standardgruppe 'Benutzer' — Systemgruppe (is_system), Name und Rechte
# sind über die UI fest und nicht änderbar. Jeder neu angelegte (nicht-admin)
# Benutzer landet automatisch darin (siehe app.py: users()). Der genaue
# Rechtesatz muss mit DEFAULT_GROUP_PERMISSIONS in app.py übereinstimmen.
cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",))
if cur.rowcount > 0:
default_group_id = cur.lastrowid
-4
View File
@@ -1,7 +1,3 @@
<!-- Live-Update-fähiges Fragment: wird von index.html initial eingebunden UND
unverändert von /dashboard/tiles (AJAX) nachgeladen, damit das Dashboard
ohne vollen Seiten-Reload aktuell bleibt (siehe dashboard-tiles.js im
scripts-Block von index.html). -->
<div id="dashboard-tiles" data-last-run-ms="{{ last_run_epoch_ms or '' }}">
<div class="stat-row">
-2
View File
@@ -80,7 +80,6 @@
</div>
{% if can_create %}
<!-- Modal: Neue Zugangsdaten -->
<div class="modal-overlay" id="addCredentialModal">
<div class="modal" style="max-width:1000px;">
<form method="post" onsubmit="return validateCredentialForm(this, 'add');">
@@ -122,7 +121,6 @@
{% endif %}
{% if can_edit %}
<!-- Modal: Zugangsdaten bearbeiten -->
<div class="modal-overlay" id="editCredentialModal">
<div class="modal" style="max-width:380px;">
<form method="post" onsubmit="return validateCredentialForm(this, 'edit');">
-12
View File
@@ -105,7 +105,6 @@
</div>
{% if can_create %}
<!-- Modal: Neues Gerät -->
<div class="modal-overlay" id="deviceModal">
<div class="modal" style="max-width:1000px;">
<form method="post" onsubmit="return validateDeviceForm(this);">
@@ -181,7 +180,6 @@
{% endif %}
{% if can_edit %}
<!-- Modal: Bearbeiten -->
<div class="modal-overlay" id="editDeviceModal">
<div class="modal" style="max-width:1000px;">
<form method="post" onsubmit="return validateDeviceForm(this);">
@@ -250,7 +248,6 @@
</div>
</div>
<!-- Modal: Switch ändern -->
<div class="modal-overlay" id="switchModal">
<div class="modal" style="max-width:380px;">
<form method="post">
@@ -279,8 +276,6 @@
{% endif %}
{% if can_create or can_edit %}
<!-- Modal: SSH-Verbindungstest (echtes Terminal, zum Akzeptieren von Host-Keys
und Prüfen der Zugangsdaten, bevor das Gerät gespeichert wird) -->
<div class="modal-overlay" id="terminalModal">
<div class="modal" style="max-width:720px;">
<div class="modal-header">
@@ -384,19 +379,12 @@ function toggleNewCredentialFields(select) {
if (fields) fields.classList.toggle("hidden", select.value !== "new");
}
function resetCredentialChoice(id) {
// Beim Öffnen sicherstellen, dass die "Neue Zugangsdaten"-Felder passend
// zur aktuellen Auswahl ein-/ausgeblendet sind (relevant v.a. nach
// vorherigem Umschalten auf "neu" ohne zu speichern).
setTimeout(() => {
const select = document.querySelector(`#${id === 'add' ? 'deviceModal' : 'editDeviceModal'} .credential-select`);
if (select) toggleNewCredentialFields(select);
}, 0);
}
// -------------------------------------------------------------------------
// SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal) — identisch zum
// Identisches Muster wie in switches.html.
// -------------------------------------------------------------------------
let term = null, fitAddon = null, termSocket = null, activePasswordInput = null;
function ensureTerminal() {
-51
View File
@@ -5,23 +5,6 @@
{% block content %}
{# Rendert einen Bereich (Geräte/Logs/Einstellungen) als kompakte, schmale
Spalte: eigene Kopfzeile mit Bereichsname + einem Kästchen (der
Kill-Switch, "Bereich sichtbar"), darunter eine kleine Tabelle mit den
Unterpunkten (Clients, Switche, ...) als Zeilen und nur den für diesen
Bereich tatsächlich genutzten Rechten (group_row_types[group_key]) als
Spalten — Logs/Einstellungen zeigen z.B. nur R/W/E, nur Geräte zeigt
zusätzlich D (PoE-Neustart über Dashboard, ausschließlich bei Clients).
"Ändern" (E) deckt Löschen überall mit ab — es gibt nirgends ein eigenes
Löschen-Recht. Zellen ohne passendes Recht für die jeweilige Zeile (z.B.
"D" bei Switche/Zugangsdaten) werden als ausgegraute, nicht anklickbare
Checkbox dargestellt statt zu fehlen — dadurch bleiben alle Spalten
bündig untereinander. readonly=true zeigt nur den aktuellen Stand
(Admin/Systemgruppen bzw. fehlendes groups.edit). Die JS-Funktion
applyPermissionGating() sperrt Kind-Rechte, solange das Bereich-Lesen
(Kill-Switch) nicht gesetzt ist. Mehrere Bereiche stehen per Flexbox
nebeneinander (siehe .permission-groups-row), damit die ganze
Rechteübersicht einer Gruppe in einer kompakten Zeile Platz hat. #}
{% macro permission_table(group, group_key, checked_keys, readonly) %}
{% set row_types = group_row_types[group_key] %}
<div class="permission-group-col">
@@ -70,10 +53,6 @@
</div>
{% endmacro %}
{# compact=true (Gruppen-Dropdown auf der Hauptseite) verkleinert Zellen-
Padding/Abstände gegenüber dem "Neue Gruppe"-Modal (compact=false, bleibt
unverändert) — gleicher Aufbau, nur enger, da im Dropdown mehr Breite
zur Verfügung steht als im schmalen Modal und es sonst auseinandergezogen wirkt. #}
{% macro permission_tree(checked_keys, readonly, compact=false) %}
<div class="permission-groups-row{{ ' permission-groups-row--compact' if compact }}">
{% for group_key, group in permission_catalog.items() %}
@@ -119,9 +98,6 @@
<th style="width:1%;">Aktionen</th>
</tr></thead>
<!-- Virtuelle "Admin"-Gruppe: Rechte sind fix (alles), Mitgliedschaft
wird direkt über is_admin gesteuert. Bleibt beim Sortieren immer
oben (eigenes <tbody data-sort-pinned>). -->
<tbody data-sort-pinned>
<tr>
<td class="cell-name">Admin <span class="pill admin">Systemrolle</span></td>
@@ -147,8 +123,6 @@
{% for g in groups %}
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
<!-- Jede Gruppe (Haupt- + Detail-Zeile) in einem eigenen <tbody>, damit
die Sortierung beide Zeilen gemeinsam verschiebt. -->
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
<tr>
<td class="cell-name">
@@ -236,7 +210,6 @@
</div>
</div>
<!-- Modal: Admin-Mitglieder verwalten -->
<div class="modal-overlay" id="adminMembersModal">
<div class="modal" style="max-width:380px;">
<form method="post">
@@ -264,7 +237,6 @@
</div>
</div>
<!-- Modals: Mitglieder pro Gruppe verwalten -->
{% for g in groups %}
<div class="modal-overlay" id="membersModal{{ loop.index }}">
<div class="modal" style="max-width:380px;">
@@ -298,9 +270,6 @@
</div>
{% endfor %}
<!-- Modal: Neue Gruppe — Rechte direkt beim Anlegen setzbar, Mitglieder
werden danach über die Gruppentabelle zugeordnet (die Gruppe muss
dafür erst existieren). -->
<div class="modal-overlay" id="addGroupModal">
<div class="modal" style="max-width:1000px;">
<form method="post">
@@ -329,9 +298,6 @@
{% block scripts %}
<script>
// Filtert ganze <tbody>-Blöcke (Haupt- + Detail-Zeile je Gruppe gemeinsam,
// analog zur Sortierung) statt einzelner <tr> -- die gepinnte Admin-Gruppe
// (data-sort-pinned) bleibt dabei immer sichtbar, unabhängig vom Suchbegriff.
function filterGroupsTable() {
const q = document.getElementById("groupSearch").value.trim().toLowerCase();
document.querySelectorAll("#groupsTable tbody").forEach(tbody => {
@@ -340,9 +306,6 @@ function filterGroupsTable() {
});
}
// Freischalten der Systemgruppe "Benutzer": zeigt statt der Nur-Lese-Ansicht
// das editierbare Formular (inkl. unlock_system_group=1) -- serverseitig
// erneut geprüft (admin + Flag), das hier ist nur die UI-Bestätigung.
function unlockSystemGroup(id) {
window.confirmAction(
"Rechte der Standardgruppe „Benutzer“ wirklich bearbeiten? Diese Gruppe ist der Login-Fallback für neue Benutzer (auch neue AD/LDAP-Konten) — zu restriktive Rechte können deren Erst-Login einschränken.",
@@ -362,12 +325,6 @@ function toggleDetail(id) {
if (chev) chev.style.transform = row.classList.contains("hidden") ? "" : "rotate(180deg)";
}
// Kind-Rechte einer Kategorie sind erst vergebbar, wenn das übergeordnete
// "Bereich anzeigen"-Recht (Kill-Switch, erste Spalte/L-Zeile) gesetzt ist —
// spiegelt serverseitig User.has_permission() (PERMISSION_PARENT_GROUP).
// Bearbeitbare Tabellen bekommen einen Live-Listener, Nur-Lese-Tabellen
// (Admin/Systemgruppen) bleiben unangetastet, deren Checkboxen sind ohnehin
// alle disabled.
function applyPermissionGating() {
document.querySelectorAll(".permission-group-col").forEach(function (area) {
const toggle = area.querySelector(".permission-area-toggle-cb");
@@ -379,9 +336,6 @@ function applyPermissionGating() {
cb.disabled = !toggle.checked;
if (!toggle.checked) cb.checked = false;
});
// Zusätzlich zum disabled-Attribut (das der Browser nur dezent
// abblendet) sichtbar ausgrauen, wie gefordert — sonst fällt
// "Bereich gesperrt" auf den ersten Blick kaum auf.
if (tbody) tbody.classList.toggle("permission-locked", !toggle.checked);
};
toggle.addEventListener("change", sync);
@@ -389,11 +343,6 @@ function applyPermissionGating() {
});
}
// R (erste Spalte/"Lesen") ist je Zeile Voraussetzung für W/E/D/X -- ein
// Recht ohne R wäre sonst über die UI nicht erreichbar (z.B. Nav-Link/Seite
// bleibt unsichtbar, obwohl das Kind-Recht technisch gesetzt ist). Abwahl
// von R nimmt deshalb automatisch auch die anderen Spalten dieser Zeile mit,
// Anwahl von W/E/D/X wählt automatisch R mit an.
function applyRowViewPrerequisite() {
document.querySelectorAll(".permission-table tbody tr").forEach(function (tr) {
const boxes = Array.from(tr.querySelectorAll(".permission-child-cb"));
-45
View File
@@ -21,7 +21,6 @@
{% endif %}
{% if current_user.is_authenticated %}
<!-- Device detail modal -->
<div class="modal-overlay" id="deviceModal">
<div class="modal">
<div class="modal-header">
@@ -50,17 +49,12 @@
Neustarten
</button>
{% endif %}
<!-- Schließen bewusst als letztes Element im DOM: modal-footer nutzt
justify-content:flex-end, damit landet der zuletzt gerenderte
Button ganz rechts — Schließen soll unabhängig davon, welche
Aktions-Buttons gerade sichtbar sind, immer rechts stehen. -->
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
</div>
</div>
</div>
{% if current_user.has_permission('devices.restart') %}
<!-- Restart confirm modal -->
<div class="modal-overlay" id="restartModal">
<div class="modal" style="max-width:400px;">
<div class="modal-header">
@@ -90,21 +84,9 @@ document.addEventListener("DOMContentLoaded", () => {
const activateButton = document.getElementById("activateButton");
const searchInput = document.getElementById("tileSearch");
// Status-Filter über die Stat-Kacheln (Online/Offline/Deaktiviert/
// Gesamt) — null = kein Filter aktiv ("Gesamt"). Zusammen mit dem
// Suchfilter kombiniert in applyFilters(), damit beide gleichzeitig
// greifen können (z.B. "offline" + Namenssuche).
let activeStatusFilter = null;
// Welche Abschnitte (per data-status) manuell eingeklappt wurden — per
// Klick auf den Abschnittstitel, unabhängig vom Status-Filter. Bleibt
// über Live-Updates hinweg erhalten (siehe applyCollapseState()).
const collapsedSections = new Set();
// Kombinierter Filter über alle Abschnitte hinweg: ein Abschnitt wird
// komplett ausgeblendet, wenn er nicht zum Status-Filter passt ODER
// keine seiner Kacheln mehr zur Namenssuche passt. Wird nach jedem
// Live-Update erneut angewendet, damit aktive Filter erhalten bleiben
// (die alten DOM-Knoten werden beim Austausch verworfen).
function applyFilters() {
const q = (searchInput ? searchInput.value : "").trim().toLowerCase();
let anyVisible = false;
@@ -121,9 +103,6 @@ document.addEventListener("DOMContentLoaded", () => {
});
const noResults = document.getElementById("noSearchResults");
if (noResults) noResults.classList.toggle("hidden", anyVisible || (q === "" && !activeStatusFilter));
// Aktive Stat-Kachel synchron halten — läuft hier statt nur im
// Klick-Handler, damit der Zustand auch nach einem Live-Update
// (frische, neu eingefügte Stat-Kacheln) sofort wieder stimmt.
document.querySelectorAll(".stat-card[data-status-filter]").forEach(c => {
const isTotal = c.dataset.statusFilter === "";
const active = activeStatusFilter === null ? isTotal : c.dataset.statusFilter === activeStatusFilter;
@@ -133,19 +112,12 @@ document.addEventListener("DOMContentLoaded", () => {
}
if (searchInput) searchInput.addEventListener("input", applyFilters);
// Ein-/Ausgeklappt-Zustand je Abschnitt anhand von collapsedSections
// anwenden — separat von der Sichtbarkeit oben: "hidden" blendet den
// ganzen Abschnitt (inkl. Titel) aus, "collapsed" versteckt nur das
// Kachel-Raster darunter, der Titel bleibt klickbar sichtbar.
function applyCollapseState() {
document.querySelectorAll(".dash-section").forEach(section => {
section.classList.toggle("collapsed", collapsedSections.has(section.dataset.status));
});
}
// Stat-Kacheln (Online/Offline/Deaktiviert/Gesamt): Klick filtert das
// Dashboard auf genau diesen Status; erneuter Klick auf die bereits
// aktive Kachel (oder auf "Gesamt") hebt den Filter wieder auf.
function bindStatCards() {
document.querySelectorAll(".stat-card[data-status-filter]").forEach(card => {
card.addEventListener("click", function () {
@@ -156,8 +128,6 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
// Abschnittstitel (z.B. "Offline"): Klick klappt nur diesen Abschnitt
// ein/aus, unabhängig vom Status-Filter oben.
function bindSectionToggles() {
document.querySelectorAll(".dash-section-title").forEach(title => {
title.addEventListener("click", function () {
@@ -168,9 +138,6 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
// Klick-Verhalten der Kacheln (Detail-Modal, Neustart/Aktivieren) — wird
// nach jedem Live-Update erneut auf die frisch eingefügten Kacheln
// angewendet, da deren alte DOM-Knoten beim Austausch verworfen werden.
function bindDeviceCards() {
if (!isAuthenticated) return;
document.querySelectorAll(".device-card").forEach(card => {
@@ -198,10 +165,6 @@ document.addEventListener("DOMContentLoaded", () => {
const pill = this.querySelector(".pill");
document.getElementById("deviceStatus").innerText = pill ? pill.innerText.trim() : "-";
if (restartButton) {
// Ohne zugewiesenen Switch + Port kann poe.sh keinen PoE-Reset
// auslösen (siehe restart_device()/poe.sh) — der Button wird für
// diese Geräte komplett ausgeblendet statt nur ausgegraut, damit
// kein Neustart vorgegaukelt wird, der tatsächlich nichts bewirkt.
const hasSwitchAndPort = this.dataset.switch !== "-" && this.dataset.port !== "-";
restartButton.style.display = (isActive && hasSwitchAndPort) ? "" : "none";
restartButton.disabled = false;
@@ -217,10 +180,6 @@ document.addEventListener("DOMContentLoaded", () => {
bindSectionToggles();
applyFilters();
// Live-Update der Dashboard-Kacheln ohne vollen Seiten-Reload: tauscht
// nur den #dashboard-tiles-Container gegen das AJAX-Partial aus (siehe
// /dashboard/tiles) und hält dabei Suchfilter, Scroll-Position und
// Sidebar-Zustand unangetastet. Läuft für an- und abgemeldete Ansicht.
let refreshTimer = null;
function scheduleNextRefresh(overrideMs) {
@@ -230,7 +189,6 @@ document.addEventListener("DOMContentLoaded", () => {
const intervalMs = pill ? parseInt(pill.dataset.intervalMs, 10) : NaN;
if (!intervalMs) return;
const lastRunMs = parseInt(pill.dataset.lastRunMs, 10);
// + kleiner Puffer, bis poe.sh die neue Zeile tatsächlich geschrieben hat.
let waitMs = intervalMs + 1500;
if (!isNaN(lastRunMs) && lastRunMs > 0) {
const elapsed = Date.now() - lastRunMs;
@@ -250,9 +208,6 @@ document.addEventListener("DOMContentLoaded", () => {
const fresh = temp.querySelector("#dashboard-tiles");
if (!fresh) return;
container.replaceWith(fresh);
// Globalen Topbar-Timer mit dem tatsächlichen Zeitpunkt
// dieses Durchlaufs synchronisieren (initCheckTimer() in
// app.js liest das Attribut bei jedem Tick neu ein).
const pill = document.getElementById("global-timer-pill");
if (pill) pill.dataset.lastRunMs = fresh.dataset.lastRunMs || "";
bindDeviceCards();
-4
View File
@@ -32,8 +32,6 @@ function colorizeLine(line) {
if (line.includes(" ist erreichbar!")) cls = "online";
else if (line.includes(" ist nicht erreichbar!")) cls = "offline";
else if (line.startsWith("----")) cls = "sep";
// Neustart-bezogene Zeilen (manueller Neustart, automatischer PoE-Restart
// bei Ausfall) einheitlich orange markieren.
else if (line.toLowerCase().includes("manuell") || line.includes("PoE")) cls = "restart";
const span = document.createElement("span");
span.className = "log-line" + (cls ? " " + cls : "");
@@ -54,8 +52,6 @@ document.addEventListener("DOMContentLoaded", () => {
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
});
box.scrollTop = box.scrollHeight;
// Dateiname mit anzeigen — ändert sich nach einem Service-Neustart
// (neues Logfile, z.B. durch "Jetzt prüfen") ohne Seiten-Reload.
if (logName) {
document.querySelectorAll("[data-log-name]").forEach((el) => { el.textContent = logName; });
}
-3
View File
@@ -29,9 +29,6 @@
<script>
function colorizeLine(line) {
let cls = "";
// Kea-Zeilenformat: "<ts> <SEVERITY> [logger/pid.tid] MESSAGE_ID text".
// Grobe Einfärbung nach Schweregrad, ohne die Logik des Live-Logs
// (online/offline) hier künstlich nachzubilden.
if (/\s(FATAL|ERROR)\s/.test(line)) cls = "offline";
else if (/\sWARN\s/.test(line)) cls = "restart";
const span = document.createElement("span");
-29
View File
@@ -68,15 +68,6 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
{% if can_run %}
<!-- Bewusst KEIN <form> hier: das säße dann innerhalb des
äußeren #updateForm (verschachtelte <form>-Elemente sind
ungültiges HTML) -- der Browser hätte das äußere Formular
an dieser Stelle vorzeitig geschlossen bzw. dessen Inhalt
zerrissen. "Update starten" hat dadurch live IMMER
zusätzlich das erste/oberste Gerät mitgeschickt, auch ganz
ohne angehakte Checkbox. Das eigentliche <form> für den
Neustart steht daher unten AUSSERHALB von #updateForm; der
Button hier referenziert es nur über "form=". -->
<button type="submit" form="reboot-{{ d['mac'] }}" class="btn btn-sm btn-secondary">Neustart</button>
{% endif %}
</div>
@@ -104,10 +95,6 @@
{% endif %}
</form>
<!-- Ein eigenständiges, NICHT verschachteltes <form> je Gerät für den
einzelnen "Neustart"-Button (siehe Kommentar oben in der Tabelle) --
jedes referenziert nur genau eine MAC, unabhängig vom Checkbox-Zustand
im großen #updateForm. -->
{% if can_run %}
{% for d in devices %}
<form method="post" action="{{ url_for('maintenance_reboot') }}" id="reboot-{{ d['mac'] }}" data-confirm="{{ d['name'] }} ({{ d['ip'] }}) jetzt per SSH neu starten?">
@@ -121,14 +108,6 @@
{% block scripts %}
<script>
// Checkboxen beim Laden IMMER zurücksetzen: Browser stellen den
// Checked-Zustand von Formularen nach einem Reload/Redirect (z.B. genau der
// Redirect nach "Update starten") gerne aus dem eigenen Verlauf wieder her,
// unabhängig davon, dass das frisch gerenderte HTML gar kein "checked"
// enthält -- ein einmal angehaktes Gerät (z.B. beim ersten Ausprobieren)
// blieb dadurch bei jedem weiteren Aufruf der Seite unsichtbar mitausgewählt
// und lief bei "Update starten" immer zusätzlich mit, ganz gleich was man
// gerade neu ankreuzt (live genau so reproduziert).
document.querySelectorAll(".maint-check").forEach(function (cb) { cb.checked = false; });
const selectAllCb = document.getElementById("selectAll");
if (selectAllCb) selectAllCb.checked = false;
@@ -142,9 +121,6 @@ document.getElementById("selectAll")?.addEventListener("change", function () {
function filterTable(inputId, tableId) {
const q = document.getElementById(inputId).value.trim().toLowerCase();
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
// Ausgabe-Zeile bleibt ausschließlich über toggleJobOutput() gesteuert,
// sonst könnte ein zufälliger Text-Treffer darin sie unabhängig vom
// dazugehörigen Geräte-Namen ein-/ausblenden.
if (row.classList.contains("empty-row") || row.classList.contains("job-output-row")) return;
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
});
@@ -161,11 +137,6 @@ function toggleJobOutput(mac) {
}
}
// "Klebt" am unteren Rand, solange man nicht selbst nach oben gescrollt hat
// -- reine Update-Ausgabe kann bei vielen Paketen sehr lang werden, ohne
// das würde man sie sonst manuell nachziehen müssen, um live mitzulesen.
// Ein kleiner Schwellwert (20px) toleriert, dass "ganz unten" durch
// Rundungsfehler beim Scrollen selten exakt 0 ist.
function isNearBottom(el) {
return el.scrollHeight - el.scrollTop - el.clientHeight < 20;
}
-5
View File
@@ -308,11 +308,6 @@ function moveNavItem(btn, dir) {
else li.parentNode.insertBefore(target, li);
}
// Lädt beim Umschalten des Interfaces (Netzwerkeinstellungen) dessen
// tatsächlichen Ist-Zustand nach -- ohne das würden die Felder weiter die
// Werte des zuvor angezeigten Interfaces zeigen, obwohl gerade ein anderes
// ausgewählt wurde (bei mehreren Interfaces leicht zu Verwechslungen
// geführt, siehe /settings/network_state).
function loadNetworkState(iface) {
fetch("{{ url_for('settings_network_state') }}?interface=" + encodeURIComponent(iface))
.then(r => r.json())
-10
View File
@@ -418,10 +418,6 @@
</table>
</div>
</div>
<!-- Modals außerhalb der Tabelle: ein <div> direkt in <tbody> ist
ungültiges HTML und wird vom Browser aus der Tabelle heraus
"foster-parented" — dabei kann die Eltern-Kind-Beziehung zwischen
Formular und Feldern zerrissen werden. -->
{% if option_defs and can_edit %}
{% for r in reservations %}
<div class="modal-overlay" id="deviceOptionsModal{{ loop.index }}">
@@ -446,10 +442,6 @@
{% endif %}
{% endfor %}
</select>
<!-- Neu ausgewählte Felder landen hier direkt unter dem Dropdown
(siehe poeShowOptionField in app.js), statt irgendwo in der
u.U. langen Liste unten sichtbar zu werden. Bereits gesetzte
Overrides stehen von Anfang an hier drin. -->
<div class="poe-added-options" style="margin:10px 0;">
{% for d in option_defs %}
{% set current = option_values.get(d.id, {}).get(r.mac, '') %}
@@ -604,8 +596,6 @@
</div>
{% endif %}
<!-- Bearbeiten-Modals außerhalb der Karte/Tabelle (siehe Kommentar oben
bei den Reservierungen — dieselbe HTML-Validität-Begründung). -->
{% if can_edit %}
{% for s in subnets %}
<div class="modal-overlay" id="editSubnetModal{{ loop.index }}">
-22
View File
@@ -83,11 +83,6 @@
</div>
</div>
<!-- Bearbeiten-Modals außerhalb der Tabelle: ein <div> direkt in <tbody>
ist ungültiges HTML — Browser "foster-parenten" es dann aus der
Tabelle heraus und zerreißen dabei mitunter die Eltern-Kind-Beziehung
zwischen Formular und Buttons (this.closest('form') lieferte dadurch
null, "Verbindung testen" öffnete kein Terminal mehr). -->
{% if can_edit %}
{% for s in switches %}
<div class="modal-overlay" id="editSwitchModal{{ loop.index }}">
@@ -151,7 +146,6 @@
{% endif %}
{% if can_create %}
<!-- Modal: Neuer Switch -->
<div class="modal-overlay" id="addSwitchModal">
<div class="modal" style="max-width:1000px;">
<form method="post" onsubmit="return validateSwitchForm(this, 'add');">
@@ -214,8 +208,6 @@
{% endif %}
{% if can_create or can_edit %}
<!-- Modal: SSH-Verbindungstest (echtes Terminal, zum Akzeptieren von Host-Keys
und Prüfen der Zugangsdaten, bevor der Switch gespeichert wird) -->
<div class="modal-overlay" id="terminalModal">
<div class="modal" style="max-width:720px;">
<div class="modal-header">
@@ -252,26 +244,17 @@
<script src="{{ url_for('static', filename='js/vendor/xterm.js') }}"></script>
<script src="{{ url_for('static', filename='js/vendor/xterm-addon-fit.js') }}"></script>
<script>
// -------------------------------------------------------------------------
// Zugangsdaten-Auswahl: "+ Neue Zugangsdaten anlegen" blendet die Felder ein
// -------------------------------------------------------------------------
function toggleNewCredentialFields(select) {
const fields = select.closest(".modal-body").querySelector(".new-credential-fields");
if (fields) fields.classList.toggle("hidden", select.value !== "new");
}
function resetCredentialChoice(id) {
// Beim Öffnen sicherstellen, dass die "Neue Zugangsdaten"-Felder passend
// zur aktuellen Auswahl ein-/ausgeblendet sind (relevant v.a. nach
// vorherigem Umschalten auf "neu" ohne zu speichern).
setTimeout(() => {
const select = document.querySelector(`#${id === 'add' ? 'addSwitchModal' : 'editSwitchModal' + id.replace('edit','')} .credential-select`);
if (select) toggleNewCredentialFields(select);
}, 0);
}
// -------------------------------------------------------------------------
// SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal)
// -------------------------------------------------------------------------
let term = null, fitAddon = null, termSocket = null, activePasswordInput = null;
function ensureTerminal() {
@@ -347,10 +330,6 @@ function openTerminal(form) {
};
termSocket.onmessage = (event) => term.write(event.data);
termSocket.onclose = () => setTermStatus("Getrennt", "offline");
// Bewusst kein Toast bei "error": manche Browser melden beim Schließen
// einer WebSocket-Verbindung ein error-Event, obwohl die Sitzung
// inhaltlich erfolgreich war. Terminal-Inhalt + Status-Pill genügen als
// Feedback — der Verbindungsstatus wird zuverlässig über onclose gepflegt.
termSocket.onerror = () => setTermStatus("Fehler", "offline");
}
@@ -376,7 +355,6 @@ if (termPasteBtn) {
});
}
// Verbindung auch beim Schließen per Klick auf Backdrop / Escape sauber trennen
const terminalModalEl = document.getElementById("terminalModal");
if (terminalModalEl) {
terminalModalEl.addEventListener("click", (e) => {
-6
View File
@@ -108,7 +108,6 @@
</div>
</div>
<!-- Modal: Neuer Benutzer -->
<div class="modal-overlay" id="userModal">
<div class="modal" style="max-width:1000px;">
<form method="post" id="userForm">
@@ -138,8 +137,6 @@
</div>
</div>
<!-- Modal: Benutzer bearbeiten (nur lokale Konten -- AD/LDAP-Konten lassen
sich nur sperren/entsperren und einer Gruppe zuweisen, siehe Tabelle) -->
<div class="modal-overlay" id="editModal">
<div class="modal">
<form method="post" id="editForm">
@@ -165,8 +162,6 @@
</div>
</div>
<!-- Modal: Aus Active Directory hinzufügen (Vorab-Zuweisung vor dem ersten
Login des AD-Benutzers) -->
{% if ldap_enabled %}
<div class="modal-overlay" id="ldapAddModal">
<div class="modal">
@@ -200,7 +195,6 @@
</div>
{% endif %}
<!-- Modal: Gruppe zuweisen (inkl. Admin als Auswahl) -->
<div class="modal-overlay" id="groupModal">
<div class="modal" style="max-width:380px;">
<form method="post" id="groupForm">