Logging & Log-Rotation, Zeitzone, Dashboard-UX, Design-Politur

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-<timestamp>.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 <interface>", 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 13:30:56 +02:00
co-authored by Claude Sonnet 5
parent d525ef2e0a
commit 7b30ebce7b
9 changed files with 714 additions and 93 deletions
+93 -7
View File
@@ -15,7 +15,18 @@ Die App ermöglicht:
Aktivieren/Deaktivieren und PoE-Neustart bleiben hinter dem Login. Eingeloggt Aktivieren/Deaktivieren und PoE-Neustart bleiben hinter dem Login. Eingeloggt
sind zusätzlich deaktivierte Geräte sichtbar (eigene Kachel-Sektion). Alle sind zusätzlich deaktivierte Geräte sichtbar (eigene Kachel-Sektion). Alle
Kacheln sind durchsuchbar und in drei Abschnitten sortiert: Offline, Online, 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 - Verwaltung von Devices und Switches
- **Zugangsdaten als eigene, wiederverwendbare Entität**: SSH-Logins werden - **Zugangsdaten als eigene, wiederverwendbare Entität**: SSH-Logins werden
einmal angelegt und können mehreren Switchen zugeordnet werden; beim 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? ## Was ist neu gegenüber dem Original?
- **Komplett neues Frontend**: eigenes Design-System (kein Bootstrap mehr), - **Komplett neues Frontend**: eigenes Design-System (kein Bootstrap mehr
Logo mittig in der Topbar (auf jeder Seite, skaliert auf die Balkenhöhe), der Hauptinhaltsbereich nutzt bewusst die volle verfügbare Breite statt
Sidebar-Navigation (nur für eingeloggte Nutzer sichtbar), Dark/Light- eines festen, Bootstrap-typischen `max-width`; nur Modals/Dialoge behalten
Umschalter, Karten-Dashboard mit Live-Zählern, moderne Modals/Toasts statt ihre kompakte, feste Breite), Logo mittig in der Topbar (auf jeder Seite,
Browser-`confirm()` und Bootstrap-Alerts. 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 - **Dashboard jetzt teilweise öffentlich**: `/` ist ohne Login erreichbar und
zeigt eine reduzierte Lese-Ansicht (Hostname, IP, Status, letzte Prüfung) zeigt eine reduzierte Lese-Ansicht (Hostname, IP, Status, letzte Prüfung)
mit Login-Button; alle Verwaltungsseiten und der PoE-Neustart bleiben 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 `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 des Kernels auf der aktuellen Adresse — zuverlässiger als die u.U. mehreren
überlagernden YAML-Dateien unter `/etc/netplan/` selbst zu parsen). ü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 <interface>`, 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 - **Änderung**: Interface, Modus (DHCP/Statisch), bei Statisch zusätzlich
IP/Prefix/Gateway, sowie unabhängig davon eigene DNS-Server (funktioniert 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 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 einsehbar unter **Logs → Änderungen** (Recht `logs_activity.view`). Zusätzlich merken
sich Geräte und Switche direkt am Datensatz (`last_modified_by`, 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 `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 PoE-Neustarts werden **bewusst nicht** im Änderungslog erfasst — die stehen
bereits im Live-Log von `poe.sh`. Eine Ausnahme ist die Änderung des bereits im Live-Log von `poe.sh`. Eine Ausnahme ist die Änderung des
Prüfintervalls selbst (eine Einstellungsänderung, kein Neustart), die wird Prüfintervalls selbst (eine Einstellungsänderung, kein Neustart), die wird
mitprotokolliert. 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-<timestamp>.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 ## Import/Export
Unter **Einstellungen** lassen sich Geräte, Switche und Zugangsdaten Unter **Einstellungen** lassen sich Geräte, Switche und Zugangsdaten
+10 -1
View File
@@ -20,7 +20,16 @@ clear
# ---- Pakete ---- # ---- Pakete ----
step "Installing system packages" step "Installing system packages"
sudo apt-get update >>/var/log/poe-manager-install.log 2>&1 && print_status "apt update" 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 ---- # ---- App-Verzeichnis ----
step "Deploying application to /srv/poe_manager" step "Deploying application to /srv/poe_manager"
+327 -18
View File
@@ -18,7 +18,7 @@ from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
from datetime import datetime 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 paramiko
import yaml 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")) 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") 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")) 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")) 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")) 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") 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 # Wird verwendet, wenn ein Switch keinen eigenen ssh_port hinterlegt hat
# (Feld leer gelassen) — deckt den Standardfall ab, ohne dass er überall # (Feld leer gelassen) — deckt den Standardfall ab, ohne dass er überall
# explizit eingetragen werden muss. # explizit eingetragen werden muss.
@@ -103,6 +130,20 @@ os.makedirs(AVATAR_DIR, exist_ok=True)
app = Flask(__name__) 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: def _load_or_create_secret() -> str:
env_secret = os.environ.get("POE_SECRET_KEY") env_secret = os.environ.get("POE_SECRET_KEY")
@@ -215,6 +256,7 @@ PERMISSIONS = {
"children": { "children": {
"logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}}, "logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}},
"logs_activity": {"label": "Änderungen", "rows": {"view": "logs_activity.view"}}, "logs_activity": {"label": "Änderungen", "rows": {"view": "logs_activity.view"}},
"logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}},
}, },
}, },
"settings_group": { "settings_group": {
@@ -272,6 +314,7 @@ PERMISSION_LABELS = {
"maintenance.view": "Wartungsseite ansehen (Geräte-Status, Job-Verlauf)", "maintenance.view": "Wartungsseite ansehen (Geräte-Status, Job-Verlauf)",
"maintenance.edit": "Wartungsaktionen ausführen (SSH-Update, SSH-Neustart)", "maintenance.edit": "Wartungsaktionen ausführen (SSH-Update, SSH-Neustart)",
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen", "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.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
"users.edit": "Benutzer ändern (inkl. Löschen)", "users.edit": "Benutzer ändern (inkl. Löschen)",
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen", "groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
@@ -342,6 +385,7 @@ NAV_ITEMS = [
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [ {"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
{"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"}, {"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
{"key": "logs_activity", "label": "Änderungen", "icon": "history", "endpoint": "activity_log"}, {"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] 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 return user.can_view_live_log
if key == "logs_activity": if key == "logs_activity":
return user.can_view_activity_log return user.can_view_activity_log
if key == "logs_kea":
return user.can_view_kea_log
return False return False
@@ -500,6 +546,10 @@ class User(UserMixin):
def can_view_activity_log(self): def can_view_activity_log(self):
return self.has_permission("logs_activity.view") return self.has_permission("logs_activity.view")
@property
def can_view_kea_log(self):
return self.has_permission("logs_kea.view")
@property @property
def can_manage_users(self): def can_manage_users(self):
return self.has_any_permission( return self.has_any_permission(
@@ -993,6 +1043,23 @@ def _ensure_schema():
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_v2,) "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-<timestamp>.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 # Standardgruppe 'Benutzer' — SYSTEMGRUPPE: Name und Rechte sind fix und
# werden bei jedem Start auf den vorgegebenen Satz zurückgesetzt (nicht # werden bei jedem Start auf den vorgegebenen Satz zurückgesetzt (nicht
# nur bei Erstanlage). Das ist unbedenklich, weil is_system die Gruppe in # 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): def log_action(action, target=None, details=None):
"""Schreibt einen Eintrag ins Änderungsverlauf-Log. Absichtlich NICHT für """Schreibt einen Eintrag ins Änderungsverlauf-Log. Absichtlich NICHT für
PoE-Neustarts genutzt (die stehen bereits im Live-Log von poe.sh).""" PoE-Neustarts genutzt (die stehen bereits im Live-Log von poe.sh)."""
who = current_user.username if current_user.is_authenticated else "system" 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 = get_db_connection()
conn.execute( conn.execute(
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)", "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.commit()
conn.close() conn.close()
_append_changes_log(ts, who, action, target, details)
def log_action_system(action, target=None, details=None): 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 Code, der außerhalb eines Request-Kontexts läuft (z.B. der
Netzwerk-Auto-Revert-Timer in einem eigenen Thread, wo current_user Netzwerk-Auto-Revert-Timer in einem eigenen Thread, wo current_user
nicht auflösbar ist und einen AttributeError werfen würde).""" 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 = get_db_connection()
conn.execute( conn.execute(
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)", "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.commit()
conn.close() conn.close()
_append_changes_log(ts, "system", action, target, details)
def touch_record(conn, table, key_col, key_val): def touch_record(conn, table, key_col, key_val):
@@ -1134,6 +1225,55 @@ def set_setting(key, value):
conn.close() 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 @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
conn = get_db_connection() conn = get_db_connection()
@@ -1167,7 +1307,11 @@ def login():
@login_required @login_required
def logout(): def logout():
logout_user() 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(): def _latest_log_file():
"""Neuestes Logfile ermitteln. Bewusst über den Dateinamen sortiert """Aktuelles Live-Log — seit der Umstellung auf eine einzige, per
(rpi-YYYYMMDDHHMMSS.log ist lexikografisch = chronologisch sortierbar), logrotate rotierte Datei (statt vorher bei jedem Dienst-Neustart ein
NICHT über Dateisystem-Metadaten wie ctime/mtime — die sind je nach neues rpi-<timestamp>.log) gibt es nur noch GENAU eine "aktuelle"
Dateisystem (z.B. unter WSL2 auf einem gemounteten Windows-Laufwerk) Datei, kein Ermitteln der "neuesten von vielen" mehr nötig."""
unzuverlässig und haben in der Praxis nicht immer das tatsächlich return POE_LIVE_LOG_PATH if os.path.exists(POE_LIVE_LOG_PATH) else None
zuletzt geschriebene Logfile geliefert."""
log_files = glob.glob(LOG_GLOB)
if not log_files:
return None
return max(log_files)
def get_last_seen(dev_name: str): def get_last_seen(dev_name: str):
"""Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war.""" """Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war. Sucht
log_files = glob.glob(LOG_GLOB) 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: if not log_files:
return None return None
@@ -1563,6 +1707,9 @@ def _detect_network_backend():
return "unknown" return "unknown"
_RESOLVED_STUB_ADDRESSES = {"127.0.0.53", "127.0.0.1", "::1"}
def _read_resolv_conf_dns(): def _read_resolv_conf_dns():
try: try:
with open("/etc/resolv.conf", encoding="utf-8") as f: with open("/etc/resolv.conf", encoding="utf-8") as f:
@@ -1571,6 +1718,49 @@ def _read_resolv_conf_dns():
return [] 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 <interface>". 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): def _read_network_state(interface, backend):
"""Aktueller Ist-Zustand — IP/Prefix/Gateway aus _detect_interface_network """Aktueller Ist-Zustand — IP/Prefix/Gateway aus _detect_interface_network
(bereits für die DHCP-Server-Funktion gebaut), DNS aus resolv.conf, (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). # /etc/netplan/ zu parsen (mehrere Dateien können sich überlagern).
if net_info.get("ok"): if net_info.get("ok"):
mode = "dhcp" if net_info.get("dynamic") else "static" 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): def _backup_network_config(interface, backend):
@@ -1858,6 +2048,25 @@ def settings():
else: else:
flash(f"Hostname konnte nicht gesetzt werden: {out}", "danger") 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: elif "confirm_network" in request.form:
token = request.form.get("confirm_network") token = request.form.get("confirm_network")
entry = _pending_network_revert.pop(token, None) entry = _pending_network_revert.pop(token, None)
@@ -1868,6 +2077,22 @@ def settings():
else: else:
flash("Keine ausstehende Bestätigung gefunden (evtl. bereits abgelaufen).", "danger") 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")) return redirect(url_for("settings"))
net_backend = _detect_network_backend() net_backend = _detect_network_backend()
@@ -1888,6 +2113,16 @@ def settings():
net_state=net_state, net_state=net_state,
net_revert_seconds=NETWORK_REVERT_SECONDS, net_revert_seconds=NETWORK_REVERT_SECONDS,
pending_network_token=pending_token, 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-def": option_def_list,
"option-data": global_option_data, "option-data": global_option_data,
"subnet4": subnet4, "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) 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"]) @app.route("/settings/dhcp", methods=["GET", "POST"])
@login_required @login_required
def settings_dhcp(): 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") @app.route("/logs/aenderungen")
@login_required @login_required
def activity_log(): def activity_log():
+29 -9
View File
@@ -13,10 +13,13 @@
--text: #eef0f4; --text: #eef0f4;
--text-dim: #9aa2b4; --text-dim: #9aa2b4;
--text-faint: #656d80; --text-faint: #656d80;
--accent: #ff7a1a; /* #ff7100 = exakte Farbe aus dem WiS-Logo (static/images/logo.png,
--accent-strong: #ff8f3d; per Pixel-Sampling ermittelt) — Buttons/Akzente sollen genau dieses
--accent-dim: rgba(255, 122, 26, 0.14); Orange treffen statt einer nur ähnlichen, frei gewählten Nuance. */
--accent-ring: rgba(255, 122, 26, 0.35); --accent: #ff7100;
--accent-strong: #ff8d33;
--accent-dim: rgba(255, 113, 0, 0.14);
--accent-ring: rgba(255, 113, 0, 0.35);
--success: #2fd07a; --success: #2fd07a;
--success-dim: rgba(47, 208, 122, 0.14); --success-dim: rgba(47, 208, 122, 0.14);
--danger: #f0475c; --danger: #f0475c;
@@ -48,7 +51,7 @@
--text: #1a1d24; --text: #1a1d24;
--text-dim: #565d6d; --text-dim: #565d6d;
--text-faint: #8a90a0; --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); --muted-dim: rgba(107, 114, 128, 0.12);
--shadow: 0 10px 30px -14px rgba(20, 20, 30, 0.18); --shadow: 0 10px 30px -14px rgba(20, 20, 30, 0.18);
--shadow-sm: 0 4px 14px -8px rgba(20, 20, 30, 0.14); --shadow-sm: 0 4px 14px -8px rgba(20, 20, 30, 0.14);
@@ -358,7 +361,6 @@ button { font-family: inherit; }
.content { .content {
padding: 24px 28px 48px; padding: 24px 28px 48px;
max-width: 1320px;
width: 100%; width: 100%;
} }
@@ -387,7 +389,7 @@ button { font-family: inherit; }
.btn-primary { .btn-primary {
background: var(--accent); background: var(--accent);
color: #1a0e03; 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); } .btn-primary:hover { background: var(--accent-strong); }
@@ -471,7 +473,12 @@ button { font-family: inherit; }
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
border-radius: var(--radius); border-radius: var(--radius);
padding: 16px 18px; 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-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 { font-size: 26px; font-weight: 700; margin-top: 6px; letter-spacing: -0.02em; }
.stat-card .stat-value.online { color: var(--success); } .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: 720px) { .device-grid { grid-template-columns: repeat(4, 1fr); } }
@media (min-width: 960px) { .device-grid { grid-template-columns: repeat(5, 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: 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 { margin-bottom: 26px; }
.dash-section-title { .dash-section-title {
@@ -593,7 +602,18 @@ button { font-family: inherit; }
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
margin-bottom: 12px; 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 { .device-card {
position: relative; position: relative;
@@ -994,8 +1014,8 @@ select {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: background:
radial-gradient(circle at 15% 15%, rgba(255, 122, 26, 0.14), transparent 45%), radial-gradient(circle at 15% 15%, rgba(255, 113, 0, 0.14), transparent 45%),
radial-gradient(circle at 85% 85%, rgba(255, 122, 26, 0.08), transparent 40%), radial-gradient(circle at 85% 85%, rgba(255, 113, 0, 0.08), transparent 40%),
var(--bg); var(--bg);
padding: 20px; padding: 20px;
} }
+12 -10
View File
@@ -5,21 +5,21 @@
<div id="dashboard-tiles" data-last-run-ms="{{ last_run_epoch_ms or '' }}"> <div id="dashboard-tiles" data-last-run-ms="{{ last_run_epoch_ms or '' }}">
<div class="stat-row"> <div class="stat-row">
<div class="stat-card"> <div class="stat-card" data-status-filter="online">
<div class="stat-label">Online</div> <div class="stat-label">Online</div>
<div class="stat-value online">{{ stats.online }}</div> <div class="stat-value online">{{ stats.online }}</div>
</div> </div>
<div class="stat-card"> <div class="stat-card" data-status-filter="offline">
<div class="stat-label">Offline</div> <div class="stat-label">Offline</div>
<div class="stat-value offline">{{ stats.offline }}</div> <div class="stat-value offline">{{ stats.offline }}</div>
</div> </div>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<div class="stat-card"> <div class="stat-card" data-status-filter="disabled">
<div class="stat-label">Deaktiviert</div> <div class="stat-label">Deaktiviert</div>
<div class="stat-value disabled">{{ stats.disabled }}</div> <div class="stat-value disabled">{{ stats.disabled }}</div>
</div> </div>
{% endif %} {% endif %}
<div class="stat-card"> <div class="stat-card" data-status-filter="">
<div class="stat-label">Gesamt</div> <div class="stat-label">Gesamt</div>
<div class="stat-value">{{ stats.total }}</div> <div class="stat-value">{{ stats.total }}</div>
</div> </div>
@@ -54,9 +54,11 @@
{% if device_count %} {% if device_count %}
{% macro section_chevron() %}<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>{% endmacro %}
{% if offline_devices %} {% if offline_devices %}
<div class="dash-section"> <div class="dash-section" data-status="offline">
<div class="dash-section-title">Offline <span class="pill offline">{{ offline_devices|length }}</span></div> <div class="dash-section-title">{{ section_chevron() }}Offline <span class="pill offline">{{ offline_devices|length }}</span></div>
<div class="device-grid"> <div class="device-grid">
{% for d in offline_devices %}{{ device_tile(d) }}{% endfor %} {% for d in offline_devices %}{{ device_tile(d) }}{% endfor %}
</div> </div>
@@ -64,8 +66,8 @@
{% endif %} {% endif %}
{% if online_devices %} {% if online_devices %}
<div class="dash-section"> <div class="dash-section" data-status="online">
<div class="dash-section-title">Online <span class="pill online">{{ online_devices|length }}</span></div> <div class="dash-section-title">{{ section_chevron() }}Online <span class="pill online">{{ online_devices|length }}</span></div>
<div class="device-grid"> <div class="device-grid">
{% for d in online_devices %}{{ device_tile(d) }}{% endfor %} {% for d in online_devices %}{{ device_tile(d) }}{% endfor %}
</div> </div>
@@ -73,8 +75,8 @@
{% endif %} {% endif %}
{% if disabled_devices %} {% if disabled_devices %}
<div class="dash-section"> <div class="dash-section" data-status="disabled">
<div class="dash-section-title">Deaktiviert <span class="pill disabled">{{ disabled_devices|length }}</span></div> <div class="dash-section-title">{{ section_chevron() }}Deaktiviert <span class="pill disabled">{{ disabled_devices|length }}</span></div>
<div class="device-grid"> <div class="device-grid">
{% for d in disabled_devices %}{{ device_tile(d) }}{% endfor %} {% for d in disabled_devices %}{{ device_tile(d) }}{% endfor %}
</div> </div>
+71 -9
View File
@@ -90,16 +90,29 @@ document.addEventListener("DOMContentLoaded", () => {
const activateButton = document.getElementById("activateButton"); const activateButton = document.getElementById("activateButton");
const searchInput = document.getElementById("tileSearch"); const searchInput = document.getElementById("tileSearch");
// Suchfilter über alle Abschnitte hinweg; Abschnitt wird komplett // Status-Filter über die Stat-Kacheln (Online/Offline/Deaktiviert/
// ausgeblendet, wenn keine seiner Kacheln mehr passt. Wird nach jedem // Gesamt) — null = kein Filter aktiv ("Gesamt"). Zusammen mit dem
// Live-Update erneut angewendet, damit ein aktiver Filter erhalten bleibt. // Suchfilter kombiniert in applyFilters(), damit beide gleichzeitig
function applySearchFilter(query) { // greifen können (z.B. "offline" + Namenssuche).
const q = (query || "").trim().toLowerCase(); 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; let anyVisible = false;
document.querySelectorAll(".dash-section").forEach(section => { document.querySelectorAll(".dash-section").forEach(section => {
const statusMatches = !activeStatusFilter || section.dataset.status === activeStatusFilter;
let sectionHasMatch = false; let sectionHasMatch = false;
section.querySelectorAll(".device-card").forEach(card => { 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); card.classList.toggle("hidden", !match);
if (match) sectionHasMatch = true; if (match) sectionHasMatch = true;
}); });
@@ -107,9 +120,53 @@ document.addEventListener("DOMContentLoaded", () => {
if (sectionHasMatch) anyVisible = true; if (sectionHasMatch) anyVisible = true;
}); });
const noResults = document.getElementById("noSearchResults"); 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 // Klick-Verhalten der Kacheln (Detail-Modal, Neustart/Aktivieren) — wird
// nach jedem Live-Update erneut auf die frisch eingefügten Kacheln // nach jedem Live-Update erneut auf die frisch eingefügten Kacheln
@@ -156,6 +213,9 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
} }
bindDeviceCards(); bindDeviceCards();
bindStatCards();
bindSectionToggles();
applyFilters();
// Live-Update der Dashboard-Kacheln ohne vollen Seiten-Reload: tauscht // Live-Update der Dashboard-Kacheln ohne vollen Seiten-Reload: tauscht
// nur den #dashboard-tiles-Container gegen das AJAX-Partial aus (siehe // 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"); const pill = document.getElementById("global-timer-pill");
if (pill) pill.dataset.lastRunMs = fresh.dataset.lastRunMs || ""; if (pill) pill.dataset.lastRunMs = fresh.dataset.lastRunMs || "";
bindDeviceCards(); bindDeviceCards();
if (searchInput) applySearchFilter(searchInput.value); bindStatCards();
bindSectionToggles();
applyFilters();
scheduleNextRefresh(); scheduleNextRefresh();
}) })
.catch(() => scheduleNextRefresh(15000)); .catch(() => scheduleNextRefresh(15000));
+65
View File
@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% set active_page = "logs" %}
{% block page_title %}Kea-DHCP{% endblock %}
{% block page_sub %}<div class="topbar-sub">Log-Ausgabe des kea-dhcp4-server-Dienstes</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Kea-DHCP-Log</h2>
<div class="hint">Eigene Log-Datei des DHCP-Dienstes (siehe Systemeinstellungen → Logs für Rotation/Aufbewahrung).</div>
</div>
<button id="refresh-btn" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
Aktualisieren
</button>
</div>
<div class="log-shell">
<div class="log-toolbar">
<div class="log-dots"><span></span><span></span><span></span></div>
<span class="text-faint mono" style="font-size:11.5px;">kea-dhcp4.log</span>
</div>
<div id="log-box">{{ log_content or "Keine Logdatei gefunden." }}</div>
</div>
{% endblock %}
{% block scripts %}
<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");
span.className = "log-line" + (cls ? " " + cls : "");
span.textContent = line;
return span;
}
document.addEventListener("DOMContentLoaded", () => {
function renderLog(text) {
const box = document.getElementById("log-box");
box.innerHTML = "";
const lines = text.split("\n");
lines.forEach((line, i) => {
box.appendChild(colorizeLine(line));
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
});
box.scrollTop = box.scrollHeight;
}
function fetchLog() {
fetch("{{ url_for('get_kea_log') }}")
.then(r => r.text().then(renderLog))
.catch(err => console.error(err));
}
document.getElementById("refresh-btn").addEventListener("click", fetchLog);
fetchLog();
});
</script>
{% endblock %}
+97 -31
View File
@@ -9,36 +9,8 @@
<div class="card card-pad"> <div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Prüfintervall</h2> <h2 style="font-size:16px;">Host</h2>
<div class="hint">Wie oft sollen Geräte auf Erreichbarkeit geprüft werden?</div> <div class="hint">Name und Zeitzone dieses Hosts.</div>
</div>
</div>
{% if current_user.has_permission('settings_system.edit') %}
<form method="post">
<div class="field">
<label for="interval">Intervall (Minuten)</label>
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
<div class="field-hint">Der Hintergrund-Dienst (rpi-check.service) wird nach dem Speichern automatisch neu gestartet.</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern & Service neustarten
</button>
</form>
{% else %}
<div class="field">
<label>Intervall (Minuten)</label>
<input type="number" value="{{ interval }}" disabled>
<div class="field-hint">Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</div>
</div>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Hostname</h2>
<div class="hint">Name dieses Hosts im Netzwerk.</div>
</div> </div>
</div> </div>
{% if current_user.has_permission('settings_system.edit') %} {% if current_user.has_permission('settings_system.edit') %}
@@ -52,15 +24,35 @@
Hostname setzen Hostname setzen
</button> </button>
</form> </form>
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
<form method="post">
<div class="field">
<label for="timezone">Zeitzone</label>
<select name="timezone" id="timezone" required>
{% for tz in timezones %}
<option value="{{ tz }}" {% if tz == current_timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
<div class="field-hint">Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Zeitzone setzen
</button>
</form>
{% else %} {% else %}
<div class="field"> <div class="field">
<label>Hostname</label> <label>Hostname</label>
<input type="text" value="{{ current_hostname or '' }}" disabled> <input type="text" value="{{ current_hostname or '' }}" disabled>
</div> </div>
<div class="field">
<label>Zeitzone</label>
<input type="text" value="{{ current_timezone or '' }}" disabled>
</div>
{% endif %} {% endif %}
</div> </div>
<div class="card card-pad" style="grid-column:1 / -1;"> <div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Netzwerkeinstellungen</h2> <h2 style="font-size:16px;">Netzwerkeinstellungen</h2>
@@ -151,5 +143,79 @@
{% endif %} {% endif %}
</div> </div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Logs</h2>
<div class="hint">Rotation & Aufbewahrung von Live-, Änderungs-, App- und Kea-DHCP-Log.</div>
</div>
</div>
{% if current_user.has_permission('settings_system.edit') %}
<form method="post">
<input type="hidden" name="save_log_rotation" value="1">
<div class="field">
<label for="log_rotation_interval">Rotations-Intervall</label>
<select name="log_rotation_interval" id="log_rotation_interval">
<option value="daily" {% if log_rotation_interval == "daily" %}selected{% endif %}>Täglich</option>
<option value="weekly" {% if log_rotation_interval == "weekly" %}selected{% endif %}>Wöchentlich</option>
<option value="monthly" {% if log_rotation_interval == "monthly" %}selected{% endif %}>Monatlich</option>
</select>
</div>
<div class="field">
<label for="log_rotation_keep">Aufbewahrung (Anzahl Rotationen)</label>
<input type="number" name="log_rotation_keep" id="log_rotation_keep" value="{{ log_rotation_keep }}" min="1" required>
<div class="field-hint">Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
</form>
{% else %}
<div class="field">
<label>Rotations-Intervall</label>
<input type="text" value="{{ log_rotation_interval }}" disabled>
</div>
<div class="field">
<label>Aufbewahrung (Anzahl Rotationen)</label>
<input type="text" value="{{ log_rotation_keep }}" disabled>
</div>
{% endif %}
<div class="field-hint mono" style="margin-top:10px; font-size:11px; line-height:1.6;">
Live: {{ log_paths.live }}<br>
Änderungen: {{ log_paths.changes }}<br>
App: {{ log_paths.app }}<br>
Kea-DHCP: {{ log_paths.kea }}
</div>
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Prüfintervall</h2>
<div class="hint">Wie oft sollen Geräte auf Erreichbarkeit geprüft werden?</div>
</div>
</div>
{% if current_user.has_permission('settings_system.edit') %}
<form method="post">
<div class="field">
<label for="interval">Intervall (Minuten)</label>
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
<div class="field-hint">Der Hintergrund-Dienst (rpi-check.service) wird nach dem Speichern automatisch neu gestartet.</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern & Service neustarten
</button>
</form>
{% else %}
<div class="field">
<label>Intervall (Minuten)</label>
<input type="number" value="{{ interval }}" disabled>
<div class="field-hint">Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</div>
</div>
{% endif %}
</div>
</div> </div>
{% endblock %} {% endblock %}
+10 -8
View File
@@ -4,14 +4,18 @@
# - prüft Erreichbarkeit von Geräten # - prüft Erreichbarkeit von Geräten
# - startet PoE-Port bei Ausfall neu # - startet PoE-Port bei Ausfall neu
# - loggt Ereignisse # - loggt Ereignisse
# - löscht alte Logfiles (>30 Tage)
# ============================================================================ # ============================================================================
LOG_DIR="/var/log" # Eine einzige, durchgehend fortgeschriebene Datei statt vorher bei jedem
LOGFILE="$LOG_DIR/rpi-$(date '+%Y%m%d%H%M%S').log" # (auch täglich per rpi-check-restart.timer automatisch ausgelösten)
# Dienst-Neustart ein neues rpi-<timestamp>.log -- Rotation/Aufbewahrung
# Alte Logfiles löschen (älter als 30 Tage) # übernimmt jetzt zentral logrotate (siehe app.py: _write_logrotate_config,
find "$LOG_DIR" -type f -name "rpi-*.log" -mtime +30 -delete # 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 # Intervall aus DB abrufen
SLEEP=$(python3 - <<'END' SLEEP=$(python3 - <<'END'
@@ -194,8 +198,6 @@ if [[ "$1" == "restart" ]]; then
exit 0 exit 0
fi fi
echo "" > "$LOGFILE"
while true; do while true; do
echo "--------------------------------------------------------------------" >> "$LOGFILE" echo "--------------------------------------------------------------------" >> "$LOGFILE"