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:
@@ -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
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user