From 7b30ebce7bf65fcf43c4434c5bc1596f855c9113 Mon Sep 17 00:00:00 2001 From: alientim Date: Wed, 12 Aug 2026 13:30:56 +0200 Subject: [PATCH] Logging & Log-Rotation, Zeitzone, Dashboard-UX, Design-Politur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logging-Infrastruktur (Logs können nach einem Import mit vielen Geräten schnell vollaufen): - Vier eigene, rotierte Log-Dateien statt allem in /var/log vermischt: Live (poe.sh, jetzt eine einzige durchgehend fortgeschriebene Datei statt vorher ein neues rpi-.log pro Dienst-Neustart), Änderungen (Spiegel der audit_log-Tabelle), App (Flask-Fehler/ Warnungen, vorher nur im journalctl-Output), Kea-DHCP (eigene Logging-Konfiguration in der generierten kea-dhcp4.conf) - Kea-DHCP-Log bewusst NICHT im gemeinsamen Verzeichnis: Keas AppArmor-Profil erlaubt Schreibzugriff exklusiv auf /var/log/kea/kea-dhcp4.log (kein Verzeichnis-Wildcard) -- live als "DENIED ... mknod ... .lock" reproduziert; die App nutzt Keas eigenen, bereits erlaubten Pfad statt das AppArmor-Profil eines Systemdienstes aufzuweichen. logrotate deckt trotzdem beide Verzeichnisse mit einer Regel ab - Neue Log-Seite "Kea-DHCP" (Logs-Bereich, eigenes Recht logs_kea.view) - Rotation (Intervall täglich/wöchentlich/monatlich, Aufbewahrung als Anzahl Rotationen) unter Systemeinstellungen → Logs einstellbar, generiert /etc/logrotate.d/poe-manager; copytruncate, da keiner der drei schreibenden Prozesse ein Reopen-Signal implementiert - Einmalige Migration räumt alte, verwaiste rpi-*.log-Dateien auf Zeitzone (Systemeinstellungen, mit Hostname in einer Kachel "Host"): setzt die System-Zeitzone per timedatectl, damit Logs/Änderungsverlauf die tatsächliche lokale Zeit statt UTC zeigen. time.tzset() direkt nach dem Setzen lässt die bereits laufende poe_web.service-Instanz die neue Zeitzone sofort übernehmen, ohne Neustart. Systemeinstellungen neu sortiert: Host + Netzwerkeinstellungen (beide Host-Konfiguration) nebeneinander, dann Logs, dann Prüfintervall. Netzwerkeinstellungen: DNS-Anzeige zeigte bisher den lokalen systemd-resolved-Stub (127.0.0.53) aus /etc/resolv.conf statt der echten Server -- jetzt über "resolvectl dns ", inkl. Fix für eine falsch geparste IPv6-Fortsetzungszeile ohne Label, und gefiltert auf IPv4 (diese Seite verwaltet nur IPv4-Konfiguration, per IPv6-Router-Advertisement gelernte Server sind hier irreführend). Dashboard: die vier Stat-Kacheln (Online/Offline/Deaktiviert/Gesamt) sind jetzt klickbare Filter (blenden alle anderen Abschnitte aus, Klick auf die aktive Kachel oder "Gesamt" hebt den Filter auf); jeder Abschnitt lässt sich unabhängig davon per Klick auf seinen Titel ein-/ ausklappen. Beide Zustände bleiben über die automatischen Live-Updates hinweg erhalten. Design-Politur: - Hauptinhaltsbereich nutzt jetzt die volle verfügbare Breite (kein Bootstrap-typisches festes max-width mehr) -- Modals bleiben bewusst bei ihrer kompakten, festen Breite Akzentfarbe exakt auf das Orange aus dem WiS-Logo abgestimmt (#ff7100, per Pixel-Sampling ermittelt) statt einer nur ähnlichen, frei gewählten Nuance - Abmelden führt zurück auf das (auch ohne Login einsehbare) Dashboard statt auf die Login-Seite Live gegen die echte Test-VM verifiziert: alle vier Logs werden geschrieben, logrotate-Konfiguration korrekt generiert, ein Zeitzonenwechsel (UTC → Europe/Berlin) wirkte sofort, DNS-Anzeige zeigt jetzt die echten IPv4-Server (1.1.1.1, 8.8.8.8) statt Stub/IPv6, Dashboard-Filter/-Collapse und Logout-Redirect per Playwright bestätigt. Co-Authored-By: Claude Sonnet 5 --- README.md | 100 ++++- install.sh | 11 +- srv/poe_manager/app.py | 345 +++++++++++++++++- srv/poe_manager/static/css/style.css | 38 +- .../templates/_dashboard_tiles.html | 22 +- srv/poe_manager/templates/index.html | 80 +++- srv/poe_manager/templates/logs_kea.html | 65 ++++ srv/poe_manager/templates/settings.html | 128 +++++-- usr/local/bin/custom/poe.sh | 18 +- 9 files changed, 714 insertions(+), 93 deletions(-) create mode 100644 srv/poe_manager/templates/logs_kea.html diff --git a/README.md b/README.md index 6a0d1d0..7fc0896 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,18 @@ Die App ermöglicht: Aktivieren/Deaktivieren und PoE-Neustart bleiben hinter dem Login. Eingeloggt sind zusätzlich deaktivierte Geräte sichtbar (eigene Kachel-Sektion). Alle Kacheln sind durchsuchbar und in drei Abschnitten sortiert: Offline, Online, - Deaktiviert — jeweils alphabetisch + Deaktiviert — jeweils alphabetisch. Die vier Stat-Kacheln oben (Online/ + Offline/Deaktiviert/Gesamt) sind klickbare Filter — ein Klick blendet alle + anderen Abschnitte aus, „Gesamt“ oder ein erneuter Klick auf die aktive + Kachel hebt den Filter wieder auf. Jeder Abschnitt lässt sich unabhängig + davon per Klick auf seinen Titel ein-/ausklappen (z.B. um eine lange + Offline-Liste vorübergehend zu verstecken, ohne den Filter zu nutzen) — + beide Zustände bleiben über die automatischen Live-Updates hinweg erhalten. + Abmelden führt zurück auf dieses (auch ohne Login einsehbare) Dashboard + statt auf die Login-Seite +- Der Hauptinhaltsbereich nutzt die **volle verfügbare Breite** (kein + Bootstrap-typisches, festes `max-width` mehr) — nur Modals/Dialoge behalten + bewusst ihre kompakte, feste Breite - Verwaltung von Devices und Switches - **Zugangsdaten als eigene, wiederverwendbare Entität**: SSH-Logins werden einmal angelegt und können mehreren Switchen zugeordnet werden; beim @@ -76,11 +87,16 @@ Die App ermöglicht: ## Was ist neu gegenüber dem Original? -- **Komplett neues Frontend**: eigenes Design-System (kein Bootstrap mehr), - Logo mittig in der Topbar (auf jeder Seite, skaliert auf die Balkenhöhe), - Sidebar-Navigation (nur für eingeloggte Nutzer sichtbar), Dark/Light- - Umschalter, Karten-Dashboard mit Live-Zählern, moderne Modals/Toasts statt - Browser-`confirm()` und Bootstrap-Alerts. +- **Komplett neues Frontend**: eigenes Design-System (kein Bootstrap mehr — + der Hauptinhaltsbereich nutzt bewusst die volle verfügbare Breite statt + eines festen, Bootstrap-typischen `max-width`; nur Modals/Dialoge behalten + ihre kompakte, feste Breite), Logo mittig in der Topbar (auf jeder Seite, + skaliert auf die Balkenhöhe), Sidebar-Navigation (nur für eingeloggte + Nutzer sichtbar), Dark/Light-Umschalter, Karten-Dashboard mit Live-Zählern, + moderne Modals/Toasts statt Browser-`confirm()` und Bootstrap-Alerts. Der + Akzent-Ton (`--accent` in `style.css`) ist exakt das Orange aus dem + WiS-Logo (`#ff7100`, per Pixel-Sampling aus `logo.png` ermittelt), nicht + nur eine ähnliche, frei gewählte Nuance. - **Dashboard jetzt teilweise öffentlich**: `/` ist ohne Login erreichbar und zeigt eine reduzierte Lese-Ansicht (Hostname, IP, Status, letzte Prüfung) mit Login-Button; alle Verwaltungsseiten und der PoE-Neustart bleiben @@ -546,6 +562,18 @@ da eine Änderung nicht die Erreichbarkeit kappt). `interface`-Block von `/etc/dhcpcd.conf`, bzw. bei netplan am `dynamic`-Flag des Kernels auf der aktuellen Adresse — zuverlässiger als die u.U. mehreren überlagernden YAML-Dateien unter `/etc/netplan/` selbst zu parsen). + **DNS-Server-Anzeige**: nicht aus `/etc/resolv.conf` gelesen — unter + systemd-resolved (Standard auf aktuellem Ubuntu) steht dort immer nur der + lokale Stub-Resolver `127.0.0.53`, nie die echten Upstream-Server (live + genau so reproduziert). Stattdessen `resolvectl dns `, dessen + mehrzeilige Ausgabe bei vielen Servern korrekt geparst wird (eine + Fortsetzungszeile ohne `Link N (iface):`-Label trägt selbst + Doppelpunkte — eine IPv6-Adresse — ein naives Aufteilen an jedem + Doppelpunkt hätte sie sonst am Anfang abgeschnitten, live genau so + reproduziert und gefixt). Zusätzlich auf **IPv4** gefiltert, da diese + Seite ausschließlich die IPv4-Konfiguration des Hosts verwaltet — von + `resolvectl` ungefragt mitgelieferte, per IPv6-Router-Advertisement + gelernte DNS-Server gehören nicht dazu und wären hier nur irreführend. - **Änderung**: Interface, Modus (DHCP/Statisch), bei Statisch zusätzlich IP/Prefix/Gateway, sowie unabhängig davon eigene DNS-Server (funktioniert in beiden Modi — z.B. IP per DHCP, aber feste DNS-Server). Bei netplan @@ -587,13 +615,71 @@ Geräten, Switchen, Zugangsdaten, Benutzern und Gruppen wird in der Tabelle einsehbar unter **Logs → Änderungen** (Recht `logs_activity.view`). Zusätzlich merken sich Geräte und Switche direkt am Datensatz (`last_modified_by`, `last_modified_at`), wer sie zuletzt geändert hat, damit man das nicht erst -im Log nachschlagen muss. +im Log nachschlagen muss. Jeder Eintrag wird zusätzlich in eine eigene Datei +gespiegelt (siehe [Logs & Log-Rotation](#logs--log-rotation)) — die +`audit_log`-Tabelle bleibt dabei die Quelle für diese durchsuchbare/ +sortierbare Seite. PoE-Neustarts werden **bewusst nicht** im Änderungslog erfasst — die stehen bereits im Live-Log von `poe.sh`. Eine Ausnahme ist die Änderung des Prüfintervalls selbst (eine Einstellungsänderung, kein Neustart), die wird mitprotokolliert. +## Logs & Log-Rotation + +Vier getrennte, eigene Log-Dateien statt allem in `/var/log` vermischt oder +nur im journalctl-Output — jede mit derselben, unter **Systemeinstellungen → +Logs** einstellbaren Rotation (Intervall täglich/wöchentlich/monatlich, +Standard wöchentlich; Aufbewahrung als Anzahl Rotationen, Standard 4 ≈ 1 +Monat Historie): + +| Log | Datei | Einsehbar unter | +|-----|-------|------------------| +| Live (Ping-Check/PoE-Neustart) | `/var/log/poe-manager/live.log` | Logs → Live | +| Änderungen (Audit-Trail) | `/var/log/poe-manager/changes.log` | Logs → Änderungen (aus der DB) | +| App (Fehler/Warnungen der Flask-App) | `/var/log/poe-manager/app.log` | nur Datei, keine eigene Seite | +| Kea-DHCP | `/var/log/kea/kea-dhcp4.log` | Logs → Kea-DHCP (Recht `logs_kea.view`) | + +- **Live-Log ist jetzt eine einzige, durchgehend fortgeschriebene Datei** + statt vorher bei jedem (auch täglich per `rpi-check-restart.timer` + automatisch ausgelösten) Dienst-Neustart ein neues `rpi-.log` + — `poe.sh` räumt nicht mehr selbst nach 30 Tagen auf, das übernimmt jetzt + zentral logrotate. Bestehende alte `rpi-*.log`-Dateien werden beim ersten + Start dieser Version einmalig aufgeräumt (Migration). +- **Kea-DHCP-Log liegt bewusst NICHT unter `/var/log/poe-manager/`**: Keas + AppArmor-Profil (`/etc/apparmor.d/usr.sbin.kea-dhcp4`) erlaubt + Schreibzugriff exklusiv auf den Dateinamen `/var/log/kea/kea-dhcp4.log` + (kein Verzeichnis-Wildcard) — ein anderer Pfad wird mit `DENIED ... mknod + ... .lock` abgelehnt (live so reproduziert). Statt das AppArmor-Profil + eines Systemdienstes aufzuweichen, nutzt die App Keas eigenen, bereits + erlaubten Pfad; die logrotate-Konfiguration deckt trotzdem beide + Verzeichnisse mit derselben Regel ab. +- **`copytruncate` statt Signal-/Neustart-basierter Rotation**: keiner der + drei schreibenden Prozesse (`poe.sh`, Flask, `kea-dhcp4-server`) + implementiert ein Log-Reopen-Signal (SIGHUP o.ä.) — `copytruncate` + funktioniert ohne jede Kooperation des schreibenden Prozesses und läuft + als root, ist also auch von Keas AppArmor-Profil nicht betroffen. +- **App-Log** (`app.log`) fängt Fehler/Warnungen der Flask-App ab (vorher + nur im journalctl-Output von `poe_web.service` sichtbar, ohne von der App + selbst kontrollierbare Aufbewahrung) — bewusst nur `WARNING` und höher, + kein Access-/Request-Rauschen. +- **Zeitzone** (Systemeinstellungen, zusammen mit dem Hostnamen in der + Kachel „Host“): setzt die System-Zeitzone per `timedatectl + set-timezone`, damit alle vier Logs sowie das Änderungslog die + tatsächliche lokale Zeit statt UTC zeigen. Wirkt über `time.tzset()` + sofort für die bereits laufende `poe_web.service`-Instanz (kein + Neustart nötig) — `poe.sh`/`date` liest die Zeitzone ohnehin bei jedem + Prüf-Durchlauf frisch, da es ein neuer Prozess pro Durchlauf ist. +- **Systemeinstellungen neu sortiert**: Host (Hostname + Zeitzone in einer + Kachel) und Netzwerkeinstellungen stehen als thematisch zusammengehörige + Host-Konfiguration nebeneinander, gefolgt von Logs und Prüfintervall. + +Live verifiziert: alle vier Log-Dateien werden geschrieben, die +logrotate-Konfiguration korrekt generiert und bei einer Änderung der +Einstellung neu geschrieben, ein Zeitzonenwechsel (UTC → Europe/Berlin) +wirkte sofort — die Log-Zeile, die den Wechsel selbst protokolliert, trug +bereits den neuen Zeitstempel. + ## Import/Export Unter **Einstellungen** lassen sich Geräte, Switche und Zugangsdaten diff --git a/install.sh b/install.sh index 3d1d1d3..be16cb0 100644 --- a/install.sh +++ b/install.sh @@ -20,7 +20,16 @@ clear # ---- Pakete ---- step "Installing system packages" sudo apt-get update >>/var/log/poe-manager-install.log 2>&1 && print_status "apt update" -sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping >>/var/log/poe-manager-install.log 2>&1 && print_status "Packages installed" +sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate >>/var/log/poe-manager-install.log 2>&1 && print_status "Packages installed" + +# ---- Log-Verzeichnis ---- +# Weltweit beschreibbar, da sowohl root (poe_web.service/poe.sh) als auch +# der i.d.R. unprivilegierte kea-dhcp4-server-Dienstbenutzer (_kea) hier +# ihre jeweils eigene Log-Datei anlegen/schreiben müssen. Die eigentliche +# logrotate-Konfiguration (/etc/logrotate.d/poe-manager) schreibt die App +# selbst beim Start bzw. beim Speichern unter Systemeinstellungen → Logs. +sudo mkdir -p /var/log/poe-manager +sudo chmod 777 /var/log/poe-manager # ---- App-Verzeichnis ---- step "Deploying application to /srv/poe_manager" diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index f9a1876..6ad2f48 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -18,7 +18,7 @@ from cryptography.fernet import Fernet from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes from datetime import datetime -import base64, csv, ipaddress, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback +import base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback import paramiko import yaml @@ -74,11 +74,38 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # --------------------------------------------------------------------------- DB_PATH = os.environ.get("POE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db")) POE_SCRIPT = os.environ.get("POE_SCRIPT", "/usr/local/bin/custom/poe.sh") -LOG_GLOB = os.environ.get("POE_LOG_GLOB", "/var/log/rpi-*.log") FERNET_KEY_PATH = os.environ.get("POE_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key")) SSH_KNOWN_HOSTS_PATH = os.environ.get("POE_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts")) SECRET_KEY_PATH = os.environ.get("POE_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key")) AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars") + +# Alle App-eigenen Logs leben unter einem gemeinsamen Verzeichnis statt +# verstreut in /var/log — vier getrennte Dateien, damit Live-Check, +# Änderungen, App-Fehler und Kea-DHCP sich nicht gegenseitig zutexten. +# Das Verzeichnis wird bewusst weltweit beschreibbar angelegt (siehe unten): +# poe_web.service/poe.sh laufen als root, kea-dhcp4-server dagegen i.d.R. +# als eigener, unprivilegierter Systembenutzer (_kea) — beide müssen in +# dasselbe Verzeichnis schreiben können, ohne dass wir hier exakt den +# richtigen UID/GID-Abgleich nachbilden müssen. +POE_LOG_DIR = os.environ.get("POE_LOG_DIR", "/var/log/poe-manager") +POE_LIVE_LOG_PATH = os.path.join(POE_LOG_DIR, "live.log") +POE_CHANGES_LOG_PATH = os.path.join(POE_LOG_DIR, "changes.log") +POE_APP_LOG_PATH = os.path.join(POE_LOG_DIR, "app.log") +# NICHT unter POE_LOG_DIR: kea-dhcp4s AppArmor-Profil +# (/etc/apparmor.d/usr.sbin.kea-dhcp4) erlaubt Schreibzugriff exklusiv auf +# genau diesen Pfad (Dateiname hart einprogrammiert, kein Verzeichnis- +# Wildcard) — ein anderer Pfad wird mit "DENIED ... mknod ... +# kea-dhcp.log.lock" abgelehnt (live so reproduziert). Bewusst NICHT das +# AppArmor-Profil aufgeweicht, um die Sandbox eines Systemdienstes auf +# einem geteilten Host nicht zu schwächen — die App arbeitet stattdessen +# mit Keas eigenem, bereits erlaubtem Pfad. +POE_KEA_LOG_PATH = os.environ.get("POE_KEA_LOG_PATH", "/var/log/kea/kea-dhcp4.log") +LOGROTATE_CONFIG_PATH = os.environ.get("POE_LOGROTATE_CONFIG", "/etc/logrotate.d/poe-manager") +try: + os.makedirs(POE_LOG_DIR, exist_ok=True) + os.chmod(POE_LOG_DIR, 0o777) +except OSError: + pass # Wird verwendet, wenn ein Switch keinen eigenen ssh_port hinterlegt hat # (Feld leer gelassen) — deckt den Standardfall ab, ohne dass er überall # explizit eingetragen werden muss. @@ -103,6 +130,20 @@ os.makedirs(AVATAR_DIR, exist_ok=True) app = Flask(__name__) +# Eigene, rotierte Datei für App-seitige Fehler/Warnungen ("Syslog"-Äquivalent +# der App) — vorher landete das nur im journalctl-Output von poe_web.service, +# ohne eigene, von der App aus kontrollierbare Aufbewahrung. Bewusst nur +# WARNING und höher (kein INFO/Request-Rauschen) — reine Fehlerdiagnose, +# kein Access-Log. +try: + _app_log_handler = logging.FileHandler(POE_APP_LOG_PATH, encoding="utf-8") + _app_log_handler.setLevel(logging.WARNING) + _app_log_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s", "%Y-%m-%d %H:%M:%S")) + app.logger.addHandler(_app_log_handler) + app.logger.setLevel(logging.WARNING) +except OSError: + pass + def _load_or_create_secret() -> str: env_secret = os.environ.get("POE_SECRET_KEY") @@ -215,6 +256,7 @@ PERMISSIONS = { "children": { "logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}}, "logs_activity": {"label": "Änderungen", "rows": {"view": "logs_activity.view"}}, + "logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}}, }, }, "settings_group": { @@ -272,6 +314,7 @@ PERMISSION_LABELS = { "maintenance.view": "Wartungsseite ansehen (Geräte-Status, Job-Verlauf)", "maintenance.edit": "Wartungsaktionen ausführen (SSH-Update, SSH-Neustart)", "logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen", + "logs_kea.view": "Kea-DHCP-Log lesen", "users.view": "Benutzer lesen", "users.create": "Benutzer anlegen", "users.edit": "Benutzer ändern (inkl. Löschen)", "groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen", @@ -342,6 +385,7 @@ NAV_ITEMS = [ {"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [ {"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"}, {"key": "logs_activity", "label": "Änderungen", "icon": "history", "endpoint": "activity_log"}, + {"key": "logs_kea", "label": "Kea-DHCP", "icon": "network", "endpoint": "kea_log"}, ]}, ] DEFAULT_NAV_ORDER = [item["key"] for item in NAV_ITEMS] @@ -373,6 +417,8 @@ def _nav_key_visible(key, user): return user.can_view_live_log if key == "logs_activity": return user.can_view_activity_log + if key == "logs_kea": + return user.can_view_kea_log return False @@ -500,6 +546,10 @@ class User(UserMixin): def can_view_activity_log(self): return self.has_permission("logs_activity.view") + @property + def can_view_kea_log(self): + return self.has_permission("logs_kea.view") + @property def can_manage_users(self): return self.has_any_permission( @@ -993,6 +1043,23 @@ def _ensure_schema(): "INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_v2,) ) + # Einmalige Aufräum-Migration: die alten, bei jedem Dienst-Neustart neu + # angelegten rpi-.log-Dateien unter /var/log sind seit der + # Umstellung auf eine einzige, per logrotate rotierte Datei + # (POE_LIVE_LOG_PATH unter /var/log/poe-manager/) verwaist — poe.sh + # räumt sie nicht mehr selbst auf (das war vorher sein eigener + # 30-Tage-`find -delete`), und die neue logrotate-Konfiguration + # überwacht ein anderes Verzeichnis. Ohne diese Migration blieben sie + # für immer liegen. + _migration_key_old_logs = "_cleaned_up_legacy_rpi_log_files_v1" + if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_old_logs,)).fetchone(): + for stale_log in glob.glob("/var/log/rpi-*.log"): + try: + os.remove(stale_log) + except OSError: + pass + conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_old_logs,)) + # Standardgruppe 'Benutzer' — SYSTEMGRUPPE: Name und Rechte sind fix und # werden bei jedem Start auf den vorgegebenen Satz zurückgesetzt (nicht # nur bei Erstanlage). Das ist unbedenklich, weil is_system die Gruppe in @@ -1062,17 +1129,39 @@ def _build_user(row): ) +def _append_changes_log(ts, who, action, target, details): + """Spiegelt jeden Änderungslog-Eintrag zusätzlich in eine eigene, per + logrotate rotierte Datei (POE_CHANGES_LOG_PATH) — die audit_log-Tabelle + bleibt die Quelle für die durchsuchbare/sortierbare Seite unter + Logs → Änderungen, wächst aber unbegrenzt weiter; die Datei bekommt + dieselbe wöchentliche Rotation/Aufbewahrung wie die anderen drei Logs. + Ein Schreibfehler hier darf den eigentlichen, DB-basierten Audit-Trail + nicht gefährden — daher bewusst best-effort mit breitem except.""" + try: + line = f"{ts} [{who}] {action}" + if target: + line += f" — {target}" + if details: + line += f": {details}" + with open(POE_CHANGES_LOG_PATH, "a", encoding="utf-8") as f: + f.write(line + "\n") + except OSError: + pass + + def log_action(action, target=None, details=None): """Schreibt einen Eintrag ins Änderungsverlauf-Log. Absichtlich NICHT für PoE-Neustarts genutzt (die stehen bereits im Live-Log von poe.sh).""" who = current_user.username if current_user.is_authenticated else "system" + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") conn = get_db_connection() conn.execute( "INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)", - (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), who, action, target, details), + (ts, who, action, target, details), ) conn.commit() conn.close() + _append_changes_log(ts, who, action, target, details) def log_action_system(action, target=None, details=None): @@ -1080,13 +1169,15 @@ def log_action_system(action, target=None, details=None): Code, der außerhalb eines Request-Kontexts läuft (z.B. der Netzwerk-Auto-Revert-Timer in einem eigenen Thread, wo current_user nicht auflösbar ist und einen AttributeError werfen würde).""" + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") conn = get_db_connection() conn.execute( "INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)", - (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "system", action, target, details), + (ts, "system", action, target, details), ) conn.commit() conn.close() + _append_changes_log(ts, "system", action, target, details) def touch_record(conn, table, key_col, key_val): @@ -1134,6 +1225,55 @@ def set_setting(key, value): conn.close() +LOG_ROTATION_INTERVALS = {"daily", "weekly", "monthly"} +LOG_ROTATION_DEFAULT_INTERVAL = "weekly" +LOG_ROTATION_DEFAULT_KEEP = 4 + + +def _write_logrotate_config(): + """Schreibt/aktualisiert die logrotate-Konfiguration für alle vier + App-Logs (Live, Änderungen, App unter POE_LOG_DIR; Kea-DHCP separat + unter POE_KEA_LOG_PATH — Keas AppArmor-Profil erlaubt nur genau diesen + einen Pfad, siehe Kommentar bei POE_KEA_LOG_PATH) anhand der aktuellen + Einstellungen (Systemeinstellungen → Logs). Läuft sowohl beim App-Start + (Rotation ist dadurch auch ohne einen expliziten Besuch der + Settings-Seite von Anfang an aktiv) als auch nach jedem Speichern dort. + "copytruncate" statt Signal-/Neustart-basierter Rotation, da keiner der + drei schreibenden Prozesse (poe.sh, Flask, kea-dhcp4-server) ein + Log-Reopen-Signal (SIGHUP o.ä.) implementiert — copytruncate + funktioniert ohne jede Kooperation des schreibenden Prozesses (Kea + eingeschlossen: logrotate läuft als root, unterliegt also nicht Keas + eigenem AppArmor-Profil).""" + interval = get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL) + if interval not in LOG_ROTATION_INTERVALS: + interval = LOG_ROTATION_DEFAULT_INTERVAL + try: + keep = int(get_setting("log_rotation_keep", LOG_ROTATION_DEFAULT_KEEP)) + if keep < 1: + keep = LOG_ROTATION_DEFAULT_KEEP + except (TypeError, ValueError): + keep = LOG_ROTATION_DEFAULT_KEEP + + content = ( + f"{POE_LOG_DIR}/*.log {POE_KEA_LOG_PATH} {{\n" + f" {interval}\n" + f" rotate {keep}\n" + f" missingok\n" + f" notifempty\n" + f" copytruncate\n" + f"}}\n" + ) + try: + with open(LOGROTATE_CONFIG_PATH, "w", encoding="utf-8") as f: + f.write(content) + return True + except OSError: + return False + + +_write_logrotate_config() + + @login_manager.user_loader def load_user(user_id): conn = get_db_connection() @@ -1167,7 +1307,11 @@ def login(): @login_required def logout(): logout_user() - return redirect(url_for("login")) + # Landet auf dem (auch ohne Login erreichbaren) Dashboard statt auf + # /login -- wer sich abmeldet, will i.d.R. trotzdem noch den + # Online/Offline-Status sehen können, ohne sich extra erneut einloggen + # zu müssen, nur um zur Übersicht zu gelangen. + return redirect(url_for("index")) # --------------------------------------------------------------------------- @@ -1253,21 +1397,21 @@ def profile(): # --------------------------------------------------------------------------- def _latest_log_file(): - """Neuestes Logfile ermitteln. Bewusst über den Dateinamen sortiert - (rpi-YYYYMMDDHHMMSS.log ist lexikografisch = chronologisch sortierbar), - NICHT über Dateisystem-Metadaten wie ctime/mtime — die sind je nach - Dateisystem (z.B. unter WSL2 auf einem gemounteten Windows-Laufwerk) - unzuverlässig und haben in der Praxis nicht immer das tatsächlich - zuletzt geschriebene Logfile geliefert.""" - log_files = glob.glob(LOG_GLOB) - if not log_files: - return None - return max(log_files) + """Aktuelles Live-Log — seit der Umstellung auf eine einzige, per + logrotate rotierte Datei (statt vorher bei jedem Dienst-Neustart ein + neues rpi-.log) gibt es nur noch GENAU eine "aktuelle" + Datei, kein Ermitteln der "neuesten von vielen" mehr nötig.""" + return POE_LIVE_LOG_PATH if os.path.exists(POE_LIVE_LOG_PATH) else None def get_last_seen(dev_name: str): - """Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war.""" - log_files = glob.glob(LOG_GLOB) + """Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war. Sucht + bewusst auch in den von logrotate rotierten Kopien (live.log.1, .2, …), + nicht nur in der aktuellen Datei — sonst würde nach einer Rotation der + "zuletzt online"-Zeitpunkt eines länger schon offline befindlichen + Geräts verloren gehen, obwohl er in einer älteren Rotation noch + vorhanden wäre.""" + log_files = glob.glob(POE_LIVE_LOG_PATH + "*") if not log_files: return None @@ -1563,6 +1707,9 @@ def _detect_network_backend(): return "unknown" +_RESOLVED_STUB_ADDRESSES = {"127.0.0.53", "127.0.0.1", "::1"} + + def _read_resolv_conf_dns(): try: with open("/etc/resolv.conf", encoding="utf-8") as f: @@ -1571,6 +1718,49 @@ def _read_resolv_conf_dns(): return [] +def _read_configured_dns(interface): + """Die tatsächlich konfigurierten (Upstream-)DNS-Server statt des + lokalen systemd-resolved-Stub-Resolvers: /etc/resolv.conf zeigt unter + systemd-resolved (Standard auf aktuellem Ubuntu) immer nur "nameserver + 127.0.0.53" — die echten Server stehen stattdessen pro Link in + "resolvectl dns ". Fällt auf resolv.conf zurück, wenn + resolvectl nicht verfügbar ist (z.B. anderes DNS-Setup ohne + systemd-resolved); 127.0.0.53/127.0.0.1/::1 werden in beiden Fällen + herausgefiltert, da sie nie ein sinnvoller "tatsächlicher" Server sind.""" + servers = [] + if shutil.which("resolvectl"): + ok, out = _dhcp_run_privileged(["resolvectl", "dns", interface], timeout=5) + if ok: + # Nur die erste Zeile trägt das "Link N (iface):"-Label; bei + # mehr Servern, als auf eine Zeile passen, folgen reine + # Fortsetzungszeilen OHNE Label -- ein naives split(":", 1) auf + # JEDER Zeile würde bei einer IPv6-Fortsetzungszeile (die selbst + # Doppelpunkte enthält) fälschlich deren Anfang abschneiden + # (live reproduziert: "2003:fe:7714:..." wurde zu "fe:7714:..."). + for line in out.splitlines(): + stripped = line.strip() + if not stripped: + continue + m = re.match(r"^Link \d+ \([^)]*\):\s*(.*)$", stripped) + servers.extend((m.group(1) if m else stripped).split()) + if not servers: + servers = _read_resolv_conf_dns() + servers = [s for s in servers if s not in _RESOLVED_STUB_ADDRESSES] + # Nur IPv4: diese Seite verwaltet ausschließlich die IPv4-Konfiguration + # des Hosts (IP-Adresse/Prefix/Gateway sind alles IPv4-Felder) — per + # IPv6-Router-Advertisement gelernte DNS-Server (die resolvectl + # ungefragt mit auflistet) gehören nicht zu dieser Konfiguration und + # wirken hier nur verwirrend/falsch neben den IPv4-Werten. + ipv4_servers = [] + for s in servers: + try: + ipaddress.IPv4Address(s) + except ValueError: + continue + ipv4_servers.append(s) + return ipv4_servers + + def _read_network_state(interface, backend): """Aktueller Ist-Zustand — IP/Prefix/Gateway aus _detect_interface_network (bereits für die DHCP-Server-Funktion gebaut), DNS aus resolv.conf, @@ -1609,7 +1799,7 @@ def _read_network_state(interface, backend): # /etc/netplan/ zu parsen (mehrere Dateien können sich überlagern). if net_info.get("ok"): mode = "dhcp" if net_info.get("dynamic") else "static" - return {**net_info, "dns": _read_resolv_conf_dns(), "mode": mode, "backend": backend} + return {**net_info, "dns": _read_configured_dns(interface), "mode": mode, "backend": backend} def _backup_network_config(interface, backend): @@ -1858,6 +2048,25 @@ def settings(): else: flash(f"Hostname konnte nicht gesetzt werden: {out}", "danger") + elif "timezone" in request.form: + new_tz = request.form.get("timezone", "").strip() + if new_tz not in _list_timezones(): + flash("Ungültige Zeitzone.", "danger") + else: + ok, out = _dhcp_run_privileged(["timedatectl", "set-timezone", new_tz], timeout=10) + if ok: + # Ohne tzset() würde diese bereits laufende poe_web.service- + # Instanz die neue Zeitzone für datetime.now() (Änderungslog, + # App-Log) erst nach einem Neustart übernehmen -- glibc + # cached die Zeitzone sonst ab dem ersten Zugriff im Prozess. + # poe.sh/date liest sie dagegen ohnehin bei jedem Aufruf frisch, + # da es ein neuer Prozess pro Durchlauf ist. + time.tzset() + log_action("settings.update", "Zeitzone", new_tz) + flash(f"Zeitzone auf „{new_tz}“ gesetzt.", "success") + else: + flash(f"Zeitzone konnte nicht gesetzt werden: {out}", "danger") + elif "confirm_network" in request.form: token = request.form.get("confirm_network") entry = _pending_network_revert.pop(token, None) @@ -1868,6 +2077,22 @@ def settings(): else: flash("Keine ausstehende Bestätigung gefunden (evtl. bereits abgelaufen).", "danger") + elif "save_log_rotation" in request.form: + new_log_interval = request.form.get("log_rotation_interval", "") + new_log_keep_raw = request.form.get("log_rotation_keep", "") + if new_log_interval not in LOG_ROTATION_INTERVALS: + flash("Ungültiges Rotations-Intervall.", "danger") + elif not new_log_keep_raw.isdigit() or int(new_log_keep_raw) < 1: + flash("Aufbewahrung muss eine Zahl ≥ 1 sein.", "danger") + else: + set_setting("log_rotation_interval", new_log_interval) + set_setting("log_rotation_keep", int(new_log_keep_raw)) + if _write_logrotate_config(): + log_action("settings.update", "Log-Rotation", f"{new_log_interval}, {new_log_keep_raw} Rotationen") + flash(f"Log-Rotation gespeichert: {new_log_interval}, {new_log_keep_raw} Rotationen aufbewahrt.", "success") + else: + flash(f"Einstellung gespeichert, aber {LOGROTATE_CONFIG_PATH} konnte nicht geschrieben werden.", "danger") + return redirect(url_for("settings")) net_backend = _detect_network_backend() @@ -1888,6 +2113,16 @@ def settings(): net_state=net_state, net_revert_seconds=NETWORK_REVERT_SECONDS, pending_network_token=pending_token, + log_rotation_interval=get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL), + log_rotation_keep=get_setting("log_rotation_keep", LOG_ROTATION_DEFAULT_KEEP), + log_paths={ + "live": POE_LIVE_LOG_PATH, + "changes": POE_CHANGES_LOG_PATH, + "app": POE_APP_LOG_PATH, + "kea": POE_KEA_LOG_PATH, + }, + current_timezone=_current_timezone(), + timezones=_list_timezones(), ) @@ -2373,6 +2608,26 @@ def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, "option-def": option_def_list, "option-data": global_option_data, "subnet4": subnet4, + # Eigene Log-Datei statt Keas Distributions-Default (meist + # Syslog) — damit sie sich zusammen mit den anderen drei + # App-Logs (Live, Änderungen, App) über dieselbe + # logrotate-Konfiguration verwalten lässt. Bewusst OHNE + # maxsize/maxver (Keas eigene, größenbasierte Rotation) — die + # externe, zeitbasierte logrotate-Rotation (siehe + # _write_logrotate_config) soll die einzige Quelle für alle + # vier Logs sein, nicht zwei parallele, unterschiedliche + # Schemata. "loggers" gehört NACH Live-Test gegen eine echte + # Kea-Instanz zwingend UNTER "Dhcp4" -- ein Top-Level- + # "Logging"-Geschwisterobjekt (wie in älterer/anderer + # Dokumentation zu finden) lehnt Kea 2.4.1 mit einem + # Syntax-Fehler ab. + "loggers": [ + { + "name": "kea-dhcp4", + "output_options": [{"output": POE_KEA_LOG_PATH}], + "severity": "INFO", + } + ], } } @@ -2388,6 +2643,21 @@ def _dhcp_run_privileged(args, timeout): return False, str(e) +def _list_timezones(): + """Alle von diesem System unterstützten IANA-Zeitzonen fürs + Auswahl-Dropdown (Systemeinstellungen → Zeitzone) — timedatectl selbst + validiert beim tatsächlichen Setzen ohnehin noch einmal serverseitig.""" + ok, out = _dhcp_run_privileged(["timedatectl", "list-timezones"], timeout=10) + if not ok: + return [] + return [line.strip() for line in out.splitlines() if line.strip()] + + +def _current_timezone(): + ok, out = _dhcp_run_privileged(["timedatectl", "show", "--property=Timezone", "--value"], timeout=5) + return out.strip() if ok else None + + @app.route("/settings/dhcp", methods=["GET", "POST"]) @login_required def settings_dhcp(): @@ -4101,6 +4371,45 @@ def logs(): ) +@app.route("/get_kea_log") +@login_required +def get_kea_log(): + if not current_user.can_view_kea_log: + return "Keine Berechtigung.", 403 + try: + with open(POE_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f: + content = f.read() + except FileNotFoundError: + content = "Noch kein Kea-Log vorhanden — der Dienst muss dafür mindestens einmal mit der aktuellen, " \ + "über \"In Datei schreiben\" erzeugten Konfiguration gelaufen sein." + except OSError as e: + content = f"Fehler beim Lesen des Logs: {e}" + return app.response_class(content, mimetype="text/plain") + + +@app.route("/logs/kea") +@login_required +def kea_log(): + """Eigene Log-Seite für den Kea-DHCP-Dienst, konsistent mit Live-Log/ + Änderungen — die Konfiguration (siehe _render_kea_config) leitet Keas + eigene Logging-Ausgabe explizit in POE_KEA_LOG_PATH statt in den + distributionsabhängigen Default (meist Syslog), damit sie sich hier + genauso wie die anderen Logs einsehen und per logrotate rotieren + lässt.""" + if not current_user.can_view_kea_log: + flash("Keine Berechtigung, das Kea-DHCP-Log einzusehen.", "danger") + return redirect(url_for("index")) + try: + with open(POE_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f: + log_content = f.read() + except FileNotFoundError: + log_content = "Noch kein Kea-Log vorhanden — der Dienst muss dafür mindestens einmal mit der aktuellen, " \ + "über \"In Datei schreiben\" erzeugten Konfiguration gelaufen sein." + except OSError as e: + log_content = f"Fehler beim Lesen des Logs: {e}" + return render_template("logs_kea.html", log_content=log_content) + + @app.route("/logs/aenderungen") @login_required def activity_log(): diff --git a/srv/poe_manager/static/css/style.css b/srv/poe_manager/static/css/style.css index 465c149..504f9d0 100644 --- a/srv/poe_manager/static/css/style.css +++ b/srv/poe_manager/static/css/style.css @@ -13,10 +13,13 @@ --text: #eef0f4; --text-dim: #9aa2b4; --text-faint: #656d80; - --accent: #ff7a1a; - --accent-strong: #ff8f3d; - --accent-dim: rgba(255, 122, 26, 0.14); - --accent-ring: rgba(255, 122, 26, 0.35); + /* #ff7100 = exakte Farbe aus dem WiS-Logo (static/images/logo.png, + per Pixel-Sampling ermittelt) — Buttons/Akzente sollen genau dieses + Orange treffen statt einer nur ähnlichen, frei gewählten Nuance. */ + --accent: #ff7100; + --accent-strong: #ff8d33; + --accent-dim: rgba(255, 113, 0, 0.14); + --accent-ring: rgba(255, 113, 0, 0.35); --success: #2fd07a; --success-dim: rgba(47, 208, 122, 0.14); --danger: #f0475c; @@ -48,7 +51,7 @@ --text: #1a1d24; --text-dim: #565d6d; --text-faint: #8a90a0; - --accent-dim: rgba(255, 122, 26, 0.1); + --accent-dim: rgba(255, 113, 0, 0.1); --muted-dim: rgba(107, 114, 128, 0.12); --shadow: 0 10px 30px -14px rgba(20, 20, 30, 0.18); --shadow-sm: 0 4px 14px -8px rgba(20, 20, 30, 0.14); @@ -358,7 +361,6 @@ button { font-family: inherit; } .content { padding: 24px 28px 48px; - max-width: 1320px; width: 100%; } @@ -387,7 +389,7 @@ button { font-family: inherit; } .btn-primary { background: var(--accent); color: #1a0e03; - box-shadow: 0 6px 18px -6px rgba(255, 122, 26, 0.55); + box-shadow: 0 6px 18px -6px rgba(255, 113, 0, 0.55); } .btn-primary:hover { background: var(--accent-strong); } @@ -471,7 +473,12 @@ button { font-family: inherit; } border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 16px 18px; + cursor: pointer; + user-select: none; + transition: border-color 0.12s ease, background 0.12s ease; } +.stat-card:hover { background: var(--bg-card-hover); } +.stat-card.active { border-color: var(--accent); background: var(--accent-dim); } .stat-card .stat-label { font-size: 12px; color: var(--text-faint); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } .stat-card .stat-value { font-size: 26px; font-weight: 700; margin-top: 6px; letter-spacing: -0.02em; } .stat-card .stat-value.online { color: var(--success); } @@ -581,6 +588,8 @@ button { font-family: inherit; } @media (min-width: 720px) { .device-grid { grid-template-columns: repeat(4, 1fr); } } @media (min-width: 960px) { .device-grid { grid-template-columns: repeat(5, 1fr); } } @media (min-width: 1200px) { .device-grid { grid-template-columns: repeat(6, 1fr); } } +@media (min-width: 1600px) { .device-grid { grid-template-columns: repeat(8, 1fr); } } +@media (min-width: 2000px) { .device-grid { grid-template-columns: repeat(10, 1fr); } } .dash-section { margin-bottom: 26px; } .dash-section-title { @@ -593,7 +602,18 @@ button { font-family: inherit; } text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 12px; + cursor: pointer; + user-select: none; + width: fit-content; } +.dash-section-title:hover { color: var(--text); } +.dash-section-title .chevron { + width: 14px; height: 14px; + transition: transform 0.15s ease; + color: var(--text-faint); +} +.dash-section.collapsed .dash-section-title .chevron { transform: rotate(-90deg); } +.dash-section.collapsed .device-grid { display: none; } .device-card { position: relative; @@ -994,8 +1014,8 @@ select { align-items: center; justify-content: center; background: - radial-gradient(circle at 15% 15%, rgba(255, 122, 26, 0.14), transparent 45%), - radial-gradient(circle at 85% 85%, rgba(255, 122, 26, 0.08), transparent 40%), + radial-gradient(circle at 15% 15%, rgba(255, 113, 0, 0.14), transparent 45%), + radial-gradient(circle at 85% 85%, rgba(255, 113, 0, 0.08), transparent 40%), var(--bg); padding: 20px; } diff --git a/srv/poe_manager/templates/_dashboard_tiles.html b/srv/poe_manager/templates/_dashboard_tiles.html index c04c0d1..f04c0a8 100644 --- a/srv/poe_manager/templates/_dashboard_tiles.html +++ b/srv/poe_manager/templates/_dashboard_tiles.html @@ -5,21 +5,21 @@
-
+
Online
{{ stats.online }}
-
+
Offline
{{ stats.offline }}
{% if current_user.is_authenticated %} -
+
Deaktiviert
{{ stats.disabled }}
{% endif %} -
+
Gesamt
{{ stats.total }}
@@ -54,9 +54,11 @@ {% if device_count %} + {% macro section_chevron() %}{% endmacro %} + {% if offline_devices %} -
-
Offline {{ offline_devices|length }}
+
+
{{ section_chevron() }}Offline {{ offline_devices|length }}
{% for d in offline_devices %}{{ device_tile(d) }}{% endfor %}
@@ -64,8 +66,8 @@ {% endif %} {% if online_devices %} -
-
Online {{ online_devices|length }}
+
+
{{ section_chevron() }}Online {{ online_devices|length }}
{% for d in online_devices %}{{ device_tile(d) }}{% endfor %}
@@ -73,8 +75,8 @@ {% endif %} {% if disabled_devices %} -
-
Deaktiviert {{ disabled_devices|length }}
+
+
{{ section_chevron() }}Deaktiviert {{ disabled_devices|length }}
{% for d in disabled_devices %}{{ device_tile(d) }}{% endfor %}
diff --git a/srv/poe_manager/templates/index.html b/srv/poe_manager/templates/index.html index 41daae2..4fba944 100644 --- a/srv/poe_manager/templates/index.html +++ b/srv/poe_manager/templates/index.html @@ -90,16 +90,29 @@ document.addEventListener("DOMContentLoaded", () => { const activateButton = document.getElementById("activateButton"); const searchInput = document.getElementById("tileSearch"); - // Suchfilter über alle Abschnitte hinweg; Abschnitt wird komplett - // ausgeblendet, wenn keine seiner Kacheln mehr passt. Wird nach jedem - // Live-Update erneut angewendet, damit ein aktiver Filter erhalten bleibt. - function applySearchFilter(query) { - const q = (query || "").trim().toLowerCase(); + // 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; document.querySelectorAll(".dash-section").forEach(section => { + const statusMatches = !activeStatusFilter || section.dataset.status === activeStatusFilter; let sectionHasMatch = false; section.querySelectorAll(".device-card").forEach(card => { - const match = card.dataset.name.toLowerCase().includes(q); + const match = statusMatches && card.dataset.name.toLowerCase().includes(q); card.classList.toggle("hidden", !match); if (match) sectionHasMatch = true; }); @@ -107,9 +120,53 @@ document.addEventListener("DOMContentLoaded", () => { if (sectionHasMatch) anyVisible = true; }); const noResults = document.getElementById("noSearchResults"); - if (noResults) noResults.classList.toggle("hidden", anyVisible || q === ""); + 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; + c.classList.toggle("active", active); + }); + applyCollapseState(); + } + 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 () { + const key = this.dataset.statusFilter; + activeStatusFilter = (!key || activeStatusFilter === key) ? null : key; + applyFilters(); + }); + }); + } + + // 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 () { + const key = this.closest(".dash-section").dataset.status; + if (collapsedSections.has(key)) collapsedSections.delete(key); else collapsedSections.add(key); + applyCollapseState(); + }); + }); } - if (searchInput) searchInput.addEventListener("input", () => applySearchFilter(searchInput.value)); // Klick-Verhalten der Kacheln (Detail-Modal, Neustart/Aktivieren) — wird // nach jedem Live-Update erneut auf die frisch eingefügten Kacheln @@ -156,6 +213,9 @@ document.addEventListener("DOMContentLoaded", () => { }); } bindDeviceCards(); + bindStatCards(); + bindSectionToggles(); + applyFilters(); // Live-Update der Dashboard-Kacheln ohne vollen Seiten-Reload: tauscht // nur den #dashboard-tiles-Container gegen das AJAX-Partial aus (siehe @@ -196,7 +256,9 @@ document.addEventListener("DOMContentLoaded", () => { const pill = document.getElementById("global-timer-pill"); if (pill) pill.dataset.lastRunMs = fresh.dataset.lastRunMs || ""; bindDeviceCards(); - if (searchInput) applySearchFilter(searchInput.value); + bindStatCards(); + bindSectionToggles(); + applyFilters(); scheduleNextRefresh(); }) .catch(() => scheduleNextRefresh(15000)); diff --git a/srv/poe_manager/templates/logs_kea.html b/srv/poe_manager/templates/logs_kea.html new file mode 100644 index 0000000..a007692 --- /dev/null +++ b/srv/poe_manager/templates/logs_kea.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% set active_page = "logs" %} +{% block page_title %}Kea-DHCP{% endblock %} +{% block page_sub %}
Log-Ausgabe des kea-dhcp4-server-Dienstes
{% endblock %} + +{% block content %} + +
+
+

Kea-DHCP-Log

+
Eigene Log-Datei des DHCP-Dienstes (siehe Systemeinstellungen → Logs für Rotation/Aufbewahrung).
+
+ +
+ +
+
+
+ kea-dhcp4.log +
+
{{ log_content or "Keine Logdatei gefunden." }}
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/srv/poe_manager/templates/settings.html b/srv/poe_manager/templates/settings.html index ec2ceff..b127afb 100644 --- a/srv/poe_manager/templates/settings.html +++ b/srv/poe_manager/templates/settings.html @@ -9,36 +9,8 @@
-

Prüfintervall

-
Wie oft sollen Geräte auf Erreichbarkeit geprüft werden?
-
-
- {% if current_user.has_permission('settings_system.edit') %} -
-
- - -
Der Hintergrund-Dienst (rpi-check.service) wird nach dem Speichern automatisch neu gestartet.
-
- -
- {% else %} -
- - -
Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.
-
- {% endif %} -
- -
-
-
-

Hostname

-
Name dieses Hosts im Netzwerk.
+

Host

+
Name und Zeitzone dieses Hosts.
{% if current_user.has_permission('settings_system.edit') %} @@ -52,15 +24,35 @@ Hostname setzen +
+
+
+ + +
Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.
+
+ +
{% else %}
+
+ + +
{% endif %}
-
+

Netzwerkeinstellungen

@@ -151,5 +143,79 @@ {% endif %}
+
+
+
+

Logs

+
Rotation & Aufbewahrung von Live-, Änderungs-, App- und Kea-DHCP-Log.
+
+
+ {% if current_user.has_permission('settings_system.edit') %} +
+ +
+ + +
+
+ + +
Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).
+
+ +
+ {% else %} +
+ + +
+
+ + +
+ {% endif %} +
+ Live: {{ log_paths.live }}
+ Änderungen: {{ log_paths.changes }}
+ App: {{ log_paths.app }}
+ Kea-DHCP: {{ log_paths.kea }} +
+
+ +
+
+
+

Prüfintervall

+
Wie oft sollen Geräte auf Erreichbarkeit geprüft werden?
+
+
+ {% if current_user.has_permission('settings_system.edit') %} +
+
+ + +
Der Hintergrund-Dienst (rpi-check.service) wird nach dem Speichern automatisch neu gestartet.
+
+ +
+ {% else %} +
+ + +
Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.
+
+ {% endif %} +
+
{% endblock %} diff --git a/usr/local/bin/custom/poe.sh b/usr/local/bin/custom/poe.sh index ada0137..51232c8 100644 --- a/usr/local/bin/custom/poe.sh +++ b/usr/local/bin/custom/poe.sh @@ -4,14 +4,18 @@ # - prüft Erreichbarkeit von Geräten # - startet PoE-Port bei Ausfall neu # - loggt Ereignisse -# - löscht alte Logfiles (>30 Tage) # ============================================================================ -LOG_DIR="/var/log" -LOGFILE="$LOG_DIR/rpi-$(date '+%Y%m%d%H%M%S').log" - -# Alte Logfiles löschen (älter als 30 Tage) -find "$LOG_DIR" -type f -name "rpi-*.log" -mtime +30 -delete +# Eine einzige, durchgehend fortgeschriebene Datei statt vorher bei jedem +# (auch täglich per rpi-check-restart.timer automatisch ausgelösten) +# Dienst-Neustart ein neues rpi-.log -- Rotation/Aufbewahrung +# übernimmt jetzt zentral logrotate (siehe app.py: _write_logrotate_config, +# einstellbar unter Systemeinstellungen → Logs), nicht mehr dieses Skript +# selbst. Pfad muss mit POE_LOG_DIR/live.log in app.py übereinstimmen. +LOG_DIR="/var/log/poe-manager" +LOGFILE="$LOG_DIR/live.log" +mkdir -p "$LOG_DIR" +touch "$LOGFILE" # Intervall aus DB abrufen SLEEP=$(python3 - <<'END' @@ -194,8 +198,6 @@ if [[ "$1" == "restart" ]]; then exit 0 fi -echo "" > "$LOGFILE" - while true; do echo "--------------------------------------------------------------------" >> "$LOGFILE"