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:
+327
-18
@@ -18,7 +18,7 @@ from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from datetime import datetime
|
||||
import base64, csv, ipaddress, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
|
||||
import base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
|
||||
import paramiko
|
||||
import yaml
|
||||
|
||||
@@ -74,11 +74,38 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
# ---------------------------------------------------------------------------
|
||||
DB_PATH = os.environ.get("POE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
|
||||
POE_SCRIPT = os.environ.get("POE_SCRIPT", "/usr/local/bin/custom/poe.sh")
|
||||
LOG_GLOB = os.environ.get("POE_LOG_GLOB", "/var/log/rpi-*.log")
|
||||
FERNET_KEY_PATH = os.environ.get("POE_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key"))
|
||||
SSH_KNOWN_HOSTS_PATH = os.environ.get("POE_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts"))
|
||||
SECRET_KEY_PATH = os.environ.get("POE_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key"))
|
||||
AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars")
|
||||
|
||||
# Alle App-eigenen Logs leben unter einem gemeinsamen Verzeichnis statt
|
||||
# verstreut in /var/log — vier getrennte Dateien, damit Live-Check,
|
||||
# Änderungen, App-Fehler und Kea-DHCP sich nicht gegenseitig zutexten.
|
||||
# Das Verzeichnis wird bewusst weltweit beschreibbar angelegt (siehe unten):
|
||||
# poe_web.service/poe.sh laufen als root, kea-dhcp4-server dagegen i.d.R.
|
||||
# als eigener, unprivilegierter Systembenutzer (_kea) — beide müssen in
|
||||
# dasselbe Verzeichnis schreiben können, ohne dass wir hier exakt den
|
||||
# richtigen UID/GID-Abgleich nachbilden müssen.
|
||||
POE_LOG_DIR = os.environ.get("POE_LOG_DIR", "/var/log/poe-manager")
|
||||
POE_LIVE_LOG_PATH = os.path.join(POE_LOG_DIR, "live.log")
|
||||
POE_CHANGES_LOG_PATH = os.path.join(POE_LOG_DIR, "changes.log")
|
||||
POE_APP_LOG_PATH = os.path.join(POE_LOG_DIR, "app.log")
|
||||
# NICHT unter POE_LOG_DIR: kea-dhcp4s AppArmor-Profil
|
||||
# (/etc/apparmor.d/usr.sbin.kea-dhcp4) erlaubt Schreibzugriff exklusiv auf
|
||||
# genau diesen Pfad (Dateiname hart einprogrammiert, kein Verzeichnis-
|
||||
# Wildcard) — ein anderer Pfad wird mit "DENIED ... mknod ...
|
||||
# kea-dhcp.log.lock" abgelehnt (live so reproduziert). Bewusst NICHT das
|
||||
# AppArmor-Profil aufgeweicht, um die Sandbox eines Systemdienstes auf
|
||||
# einem geteilten Host nicht zu schwächen — die App arbeitet stattdessen
|
||||
# mit Keas eigenem, bereits erlaubtem Pfad.
|
||||
POE_KEA_LOG_PATH = os.environ.get("POE_KEA_LOG_PATH", "/var/log/kea/kea-dhcp4.log")
|
||||
LOGROTATE_CONFIG_PATH = os.environ.get("POE_LOGROTATE_CONFIG", "/etc/logrotate.d/poe-manager")
|
||||
try:
|
||||
os.makedirs(POE_LOG_DIR, exist_ok=True)
|
||||
os.chmod(POE_LOG_DIR, 0o777)
|
||||
except OSError:
|
||||
pass
|
||||
# Wird verwendet, wenn ein Switch keinen eigenen ssh_port hinterlegt hat
|
||||
# (Feld leer gelassen) — deckt den Standardfall ab, ohne dass er überall
|
||||
# explizit eingetragen werden muss.
|
||||
@@ -103,6 +130,20 @@ os.makedirs(AVATAR_DIR, exist_ok=True)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Eigene, rotierte Datei für App-seitige Fehler/Warnungen ("Syslog"-Äquivalent
|
||||
# der App) — vorher landete das nur im journalctl-Output von poe_web.service,
|
||||
# ohne eigene, von der App aus kontrollierbare Aufbewahrung. Bewusst nur
|
||||
# WARNING und höher (kein INFO/Request-Rauschen) — reine Fehlerdiagnose,
|
||||
# kein Access-Log.
|
||||
try:
|
||||
_app_log_handler = logging.FileHandler(POE_APP_LOG_PATH, encoding="utf-8")
|
||||
_app_log_handler.setLevel(logging.WARNING)
|
||||
_app_log_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s", "%Y-%m-%d %H:%M:%S"))
|
||||
app.logger.addHandler(_app_log_handler)
|
||||
app.logger.setLevel(logging.WARNING)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _load_or_create_secret() -> str:
|
||||
env_secret = os.environ.get("POE_SECRET_KEY")
|
||||
@@ -215,6 +256,7 @@ PERMISSIONS = {
|
||||
"children": {
|
||||
"logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}},
|
||||
"logs_activity": {"label": "Änderungen", "rows": {"view": "logs_activity.view"}},
|
||||
"logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}},
|
||||
},
|
||||
},
|
||||
"settings_group": {
|
||||
@@ -272,6 +314,7 @@ PERMISSION_LABELS = {
|
||||
"maintenance.view": "Wartungsseite ansehen (Geräte-Status, Job-Verlauf)",
|
||||
"maintenance.edit": "Wartungsaktionen ausführen (SSH-Update, SSH-Neustart)",
|
||||
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen",
|
||||
"logs_kea.view": "Kea-DHCP-Log lesen",
|
||||
"users.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
|
||||
"users.edit": "Benutzer ändern (inkl. Löschen)",
|
||||
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
|
||||
@@ -342,6 +385,7 @@ NAV_ITEMS = [
|
||||
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
|
||||
{"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
|
||||
{"key": "logs_activity", "label": "Änderungen", "icon": "history", "endpoint": "activity_log"},
|
||||
{"key": "logs_kea", "label": "Kea-DHCP", "icon": "network", "endpoint": "kea_log"},
|
||||
]},
|
||||
]
|
||||
DEFAULT_NAV_ORDER = [item["key"] for item in NAV_ITEMS]
|
||||
@@ -373,6 +417,8 @@ def _nav_key_visible(key, user):
|
||||
return user.can_view_live_log
|
||||
if key == "logs_activity":
|
||||
return user.can_view_activity_log
|
||||
if key == "logs_kea":
|
||||
return user.can_view_kea_log
|
||||
return False
|
||||
|
||||
|
||||
@@ -500,6 +546,10 @@ class User(UserMixin):
|
||||
def can_view_activity_log(self):
|
||||
return self.has_permission("logs_activity.view")
|
||||
|
||||
@property
|
||||
def can_view_kea_log(self):
|
||||
return self.has_permission("logs_kea.view")
|
||||
|
||||
@property
|
||||
def can_manage_users(self):
|
||||
return self.has_any_permission(
|
||||
@@ -993,6 +1043,23 @@ def _ensure_schema():
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_v2,)
|
||||
)
|
||||
|
||||
# Einmalige Aufräum-Migration: die alten, bei jedem Dienst-Neustart neu
|
||||
# angelegten rpi-<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
|
||||
# werden bei jedem Start auf den vorgegebenen Satz zurückgesetzt (nicht
|
||||
# nur bei Erstanlage). Das ist unbedenklich, weil is_system die Gruppe in
|
||||
@@ -1062,17 +1129,39 @@ def _build_user(row):
|
||||
)
|
||||
|
||||
|
||||
def _append_changes_log(ts, who, action, target, details):
|
||||
"""Spiegelt jeden Änderungslog-Eintrag zusätzlich in eine eigene, per
|
||||
logrotate rotierte Datei (POE_CHANGES_LOG_PATH) — die audit_log-Tabelle
|
||||
bleibt die Quelle für die durchsuchbare/sortierbare Seite unter
|
||||
Logs → Änderungen, wächst aber unbegrenzt weiter; die Datei bekommt
|
||||
dieselbe wöchentliche Rotation/Aufbewahrung wie die anderen drei Logs.
|
||||
Ein Schreibfehler hier darf den eigentlichen, DB-basierten Audit-Trail
|
||||
nicht gefährden — daher bewusst best-effort mit breitem except."""
|
||||
try:
|
||||
line = f"{ts} [{who}] {action}"
|
||||
if target:
|
||||
line += f" — {target}"
|
||||
if details:
|
||||
line += f": {details}"
|
||||
with open(POE_CHANGES_LOG_PATH, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def log_action(action, target=None, details=None):
|
||||
"""Schreibt einen Eintrag ins Änderungsverlauf-Log. Absichtlich NICHT für
|
||||
PoE-Neustarts genutzt (die stehen bereits im Live-Log von poe.sh)."""
|
||||
who = current_user.username if current_user.is_authenticated else "system"
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn = get_db_connection()
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
|
||||
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), who, action, target, details),
|
||||
(ts, who, action, target, details),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
_append_changes_log(ts, who, action, target, details)
|
||||
|
||||
|
||||
def log_action_system(action, target=None, details=None):
|
||||
@@ -1080,13 +1169,15 @@ def log_action_system(action, target=None, details=None):
|
||||
Code, der außerhalb eines Request-Kontexts läuft (z.B. der
|
||||
Netzwerk-Auto-Revert-Timer in einem eigenen Thread, wo current_user
|
||||
nicht auflösbar ist und einen AttributeError werfen würde)."""
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn = get_db_connection()
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
|
||||
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "system", action, target, details),
|
||||
(ts, "system", action, target, details),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
_append_changes_log(ts, "system", action, target, details)
|
||||
|
||||
|
||||
def touch_record(conn, table, key_col, key_val):
|
||||
@@ -1134,6 +1225,55 @@ def set_setting(key, value):
|
||||
conn.close()
|
||||
|
||||
|
||||
LOG_ROTATION_INTERVALS = {"daily", "weekly", "monthly"}
|
||||
LOG_ROTATION_DEFAULT_INTERVAL = "weekly"
|
||||
LOG_ROTATION_DEFAULT_KEEP = 4
|
||||
|
||||
|
||||
def _write_logrotate_config():
|
||||
"""Schreibt/aktualisiert die logrotate-Konfiguration für alle vier
|
||||
App-Logs (Live, Änderungen, App unter POE_LOG_DIR; Kea-DHCP separat
|
||||
unter POE_KEA_LOG_PATH — Keas AppArmor-Profil erlaubt nur genau diesen
|
||||
einen Pfad, siehe Kommentar bei POE_KEA_LOG_PATH) anhand der aktuellen
|
||||
Einstellungen (Systemeinstellungen → Logs). Läuft sowohl beim App-Start
|
||||
(Rotation ist dadurch auch ohne einen expliziten Besuch der
|
||||
Settings-Seite von Anfang an aktiv) als auch nach jedem Speichern dort.
|
||||
"copytruncate" statt Signal-/Neustart-basierter Rotation, da keiner der
|
||||
drei schreibenden Prozesse (poe.sh, Flask, kea-dhcp4-server) ein
|
||||
Log-Reopen-Signal (SIGHUP o.ä.) implementiert — copytruncate
|
||||
funktioniert ohne jede Kooperation des schreibenden Prozesses (Kea
|
||||
eingeschlossen: logrotate läuft als root, unterliegt also nicht Keas
|
||||
eigenem AppArmor-Profil)."""
|
||||
interval = get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL)
|
||||
if interval not in LOG_ROTATION_INTERVALS:
|
||||
interval = LOG_ROTATION_DEFAULT_INTERVAL
|
||||
try:
|
||||
keep = int(get_setting("log_rotation_keep", LOG_ROTATION_DEFAULT_KEEP))
|
||||
if keep < 1:
|
||||
keep = LOG_ROTATION_DEFAULT_KEEP
|
||||
except (TypeError, ValueError):
|
||||
keep = LOG_ROTATION_DEFAULT_KEEP
|
||||
|
||||
content = (
|
||||
f"{POE_LOG_DIR}/*.log {POE_KEA_LOG_PATH} {{\n"
|
||||
f" {interval}\n"
|
||||
f" rotate {keep}\n"
|
||||
f" missingok\n"
|
||||
f" notifempty\n"
|
||||
f" copytruncate\n"
|
||||
f"}}\n"
|
||||
)
|
||||
try:
|
||||
with open(LOGROTATE_CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
_write_logrotate_config()
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
conn = get_db_connection()
|
||||
@@ -1167,7 +1307,11 @@ def login():
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for("login"))
|
||||
# Landet auf dem (auch ohne Login erreichbaren) Dashboard statt auf
|
||||
# /login -- wer sich abmeldet, will i.d.R. trotzdem noch den
|
||||
# Online/Offline-Status sehen können, ohne sich extra erneut einloggen
|
||||
# zu müssen, nur um zur Übersicht zu gelangen.
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1253,21 +1397,21 @@ def profile():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _latest_log_file():
|
||||
"""Neuestes Logfile ermitteln. Bewusst über den Dateinamen sortiert
|
||||
(rpi-YYYYMMDDHHMMSS.log ist lexikografisch = chronologisch sortierbar),
|
||||
NICHT über Dateisystem-Metadaten wie ctime/mtime — die sind je nach
|
||||
Dateisystem (z.B. unter WSL2 auf einem gemounteten Windows-Laufwerk)
|
||||
unzuverlässig und haben in der Praxis nicht immer das tatsächlich
|
||||
zuletzt geschriebene Logfile geliefert."""
|
||||
log_files = glob.glob(LOG_GLOB)
|
||||
if not log_files:
|
||||
return None
|
||||
return max(log_files)
|
||||
"""Aktuelles Live-Log — seit der Umstellung auf eine einzige, per
|
||||
logrotate rotierte Datei (statt vorher bei jedem Dienst-Neustart ein
|
||||
neues rpi-<timestamp>.log) gibt es nur noch GENAU eine "aktuelle"
|
||||
Datei, kein Ermitteln der "neuesten von vielen" mehr nötig."""
|
||||
return POE_LIVE_LOG_PATH if os.path.exists(POE_LIVE_LOG_PATH) else None
|
||||
|
||||
|
||||
def get_last_seen(dev_name: str):
|
||||
"""Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war."""
|
||||
log_files = glob.glob(LOG_GLOB)
|
||||
"""Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war. Sucht
|
||||
bewusst auch in den von logrotate rotierten Kopien (live.log.1, .2, …),
|
||||
nicht nur in der aktuellen Datei — sonst würde nach einer Rotation der
|
||||
"zuletzt online"-Zeitpunkt eines länger schon offline befindlichen
|
||||
Geräts verloren gehen, obwohl er in einer älteren Rotation noch
|
||||
vorhanden wäre."""
|
||||
log_files = glob.glob(POE_LIVE_LOG_PATH + "*")
|
||||
if not log_files:
|
||||
return None
|
||||
|
||||
@@ -1563,6 +1707,9 @@ def _detect_network_backend():
|
||||
return "unknown"
|
||||
|
||||
|
||||
_RESOLVED_STUB_ADDRESSES = {"127.0.0.53", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def _read_resolv_conf_dns():
|
||||
try:
|
||||
with open("/etc/resolv.conf", encoding="utf-8") as f:
|
||||
@@ -1571,6 +1718,49 @@ def _read_resolv_conf_dns():
|
||||
return []
|
||||
|
||||
|
||||
def _read_configured_dns(interface):
|
||||
"""Die tatsächlich konfigurierten (Upstream-)DNS-Server statt des
|
||||
lokalen systemd-resolved-Stub-Resolvers: /etc/resolv.conf zeigt unter
|
||||
systemd-resolved (Standard auf aktuellem Ubuntu) immer nur "nameserver
|
||||
127.0.0.53" — die echten Server stehen stattdessen pro Link in
|
||||
"resolvectl dns <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):
|
||||
"""Aktueller Ist-Zustand — IP/Prefix/Gateway aus _detect_interface_network
|
||||
(bereits für die DHCP-Server-Funktion gebaut), DNS aus resolv.conf,
|
||||
@@ -1609,7 +1799,7 @@ def _read_network_state(interface, backend):
|
||||
# /etc/netplan/ zu parsen (mehrere Dateien können sich überlagern).
|
||||
if net_info.get("ok"):
|
||||
mode = "dhcp" if net_info.get("dynamic") else "static"
|
||||
return {**net_info, "dns": _read_resolv_conf_dns(), "mode": mode, "backend": backend}
|
||||
return {**net_info, "dns": _read_configured_dns(interface), "mode": mode, "backend": backend}
|
||||
|
||||
|
||||
def _backup_network_config(interface, backend):
|
||||
@@ -1858,6 +2048,25 @@ def settings():
|
||||
else:
|
||||
flash(f"Hostname konnte nicht gesetzt werden: {out}", "danger")
|
||||
|
||||
elif "timezone" in request.form:
|
||||
new_tz = request.form.get("timezone", "").strip()
|
||||
if new_tz not in _list_timezones():
|
||||
flash("Ungültige Zeitzone.", "danger")
|
||||
else:
|
||||
ok, out = _dhcp_run_privileged(["timedatectl", "set-timezone", new_tz], timeout=10)
|
||||
if ok:
|
||||
# Ohne tzset() würde diese bereits laufende poe_web.service-
|
||||
# Instanz die neue Zeitzone für datetime.now() (Änderungslog,
|
||||
# App-Log) erst nach einem Neustart übernehmen -- glibc
|
||||
# cached die Zeitzone sonst ab dem ersten Zugriff im Prozess.
|
||||
# poe.sh/date liest sie dagegen ohnehin bei jedem Aufruf frisch,
|
||||
# da es ein neuer Prozess pro Durchlauf ist.
|
||||
time.tzset()
|
||||
log_action("settings.update", "Zeitzone", new_tz)
|
||||
flash(f"Zeitzone auf „{new_tz}“ gesetzt.", "success")
|
||||
else:
|
||||
flash(f"Zeitzone konnte nicht gesetzt werden: {out}", "danger")
|
||||
|
||||
elif "confirm_network" in request.form:
|
||||
token = request.form.get("confirm_network")
|
||||
entry = _pending_network_revert.pop(token, None)
|
||||
@@ -1868,6 +2077,22 @@ def settings():
|
||||
else:
|
||||
flash("Keine ausstehende Bestätigung gefunden (evtl. bereits abgelaufen).", "danger")
|
||||
|
||||
elif "save_log_rotation" in request.form:
|
||||
new_log_interval = request.form.get("log_rotation_interval", "")
|
||||
new_log_keep_raw = request.form.get("log_rotation_keep", "")
|
||||
if new_log_interval not in LOG_ROTATION_INTERVALS:
|
||||
flash("Ungültiges Rotations-Intervall.", "danger")
|
||||
elif not new_log_keep_raw.isdigit() or int(new_log_keep_raw) < 1:
|
||||
flash("Aufbewahrung muss eine Zahl ≥ 1 sein.", "danger")
|
||||
else:
|
||||
set_setting("log_rotation_interval", new_log_interval)
|
||||
set_setting("log_rotation_keep", int(new_log_keep_raw))
|
||||
if _write_logrotate_config():
|
||||
log_action("settings.update", "Log-Rotation", f"{new_log_interval}, {new_log_keep_raw} Rotationen")
|
||||
flash(f"Log-Rotation gespeichert: {new_log_interval}, {new_log_keep_raw} Rotationen aufbewahrt.", "success")
|
||||
else:
|
||||
flash(f"Einstellung gespeichert, aber {LOGROTATE_CONFIG_PATH} konnte nicht geschrieben werden.", "danger")
|
||||
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
net_backend = _detect_network_backend()
|
||||
@@ -1888,6 +2113,16 @@ def settings():
|
||||
net_state=net_state,
|
||||
net_revert_seconds=NETWORK_REVERT_SECONDS,
|
||||
pending_network_token=pending_token,
|
||||
log_rotation_interval=get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL),
|
||||
log_rotation_keep=get_setting("log_rotation_keep", LOG_ROTATION_DEFAULT_KEEP),
|
||||
log_paths={
|
||||
"live": POE_LIVE_LOG_PATH,
|
||||
"changes": POE_CHANGES_LOG_PATH,
|
||||
"app": POE_APP_LOG_PATH,
|
||||
"kea": POE_KEA_LOG_PATH,
|
||||
},
|
||||
current_timezone=_current_timezone(),
|
||||
timezones=_list_timezones(),
|
||||
)
|
||||
|
||||
|
||||
@@ -2373,6 +2608,26 @@ def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs,
|
||||
"option-def": option_def_list,
|
||||
"option-data": global_option_data,
|
||||
"subnet4": subnet4,
|
||||
# Eigene Log-Datei statt Keas Distributions-Default (meist
|
||||
# Syslog) — damit sie sich zusammen mit den anderen drei
|
||||
# App-Logs (Live, Änderungen, App) über dieselbe
|
||||
# logrotate-Konfiguration verwalten lässt. Bewusst OHNE
|
||||
# maxsize/maxver (Keas eigene, größenbasierte Rotation) — die
|
||||
# externe, zeitbasierte logrotate-Rotation (siehe
|
||||
# _write_logrotate_config) soll die einzige Quelle für alle
|
||||
# vier Logs sein, nicht zwei parallele, unterschiedliche
|
||||
# Schemata. "loggers" gehört NACH Live-Test gegen eine echte
|
||||
# Kea-Instanz zwingend UNTER "Dhcp4" -- ein Top-Level-
|
||||
# "Logging"-Geschwisterobjekt (wie in älterer/anderer
|
||||
# Dokumentation zu finden) lehnt Kea 2.4.1 mit einem
|
||||
# Syntax-Fehler ab.
|
||||
"loggers": [
|
||||
{
|
||||
"name": "kea-dhcp4",
|
||||
"output_options": [{"output": POE_KEA_LOG_PATH}],
|
||||
"severity": "INFO",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2388,6 +2643,21 @@ def _dhcp_run_privileged(args, timeout):
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def _list_timezones():
|
||||
"""Alle von diesem System unterstützten IANA-Zeitzonen fürs
|
||||
Auswahl-Dropdown (Systemeinstellungen → Zeitzone) — timedatectl selbst
|
||||
validiert beim tatsächlichen Setzen ohnehin noch einmal serverseitig."""
|
||||
ok, out = _dhcp_run_privileged(["timedatectl", "list-timezones"], timeout=10)
|
||||
if not ok:
|
||||
return []
|
||||
return [line.strip() for line in out.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _current_timezone():
|
||||
ok, out = _dhcp_run_privileged(["timedatectl", "show", "--property=Timezone", "--value"], timeout=5)
|
||||
return out.strip() if ok else None
|
||||
|
||||
|
||||
@app.route("/settings/dhcp", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def settings_dhcp():
|
||||
@@ -4101,6 +4371,45 @@ def logs():
|
||||
)
|
||||
|
||||
|
||||
@app.route("/get_kea_log")
|
||||
@login_required
|
||||
def get_kea_log():
|
||||
if not current_user.can_view_kea_log:
|
||||
return "Keine Berechtigung.", 403
|
||||
try:
|
||||
with open(POE_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
except FileNotFoundError:
|
||||
content = "Noch kein Kea-Log vorhanden — der Dienst muss dafür mindestens einmal mit der aktuellen, " \
|
||||
"über \"In Datei schreiben\" erzeugten Konfiguration gelaufen sein."
|
||||
except OSError as e:
|
||||
content = f"Fehler beim Lesen des Logs: {e}"
|
||||
return app.response_class(content, mimetype="text/plain")
|
||||
|
||||
|
||||
@app.route("/logs/kea")
|
||||
@login_required
|
||||
def kea_log():
|
||||
"""Eigene Log-Seite für den Kea-DHCP-Dienst, konsistent mit Live-Log/
|
||||
Änderungen — die Konfiguration (siehe _render_kea_config) leitet Keas
|
||||
eigene Logging-Ausgabe explizit in POE_KEA_LOG_PATH statt in den
|
||||
distributionsabhängigen Default (meist Syslog), damit sie sich hier
|
||||
genauso wie die anderen Logs einsehen und per logrotate rotieren
|
||||
lässt."""
|
||||
if not current_user.can_view_kea_log:
|
||||
flash("Keine Berechtigung, das Kea-DHCP-Log einzusehen.", "danger")
|
||||
return redirect(url_for("index"))
|
||||
try:
|
||||
with open(POE_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f:
|
||||
log_content = f.read()
|
||||
except FileNotFoundError:
|
||||
log_content = "Noch kein Kea-Log vorhanden — der Dienst muss dafür mindestens einmal mit der aktuellen, " \
|
||||
"über \"In Datei schreiben\" erzeugten Konfiguration gelaufen sein."
|
||||
except OSError as e:
|
||||
log_content = f"Fehler beim Lesen des Logs: {e}"
|
||||
return render_template("logs_kea.html", log_content=log_content)
|
||||
|
||||
|
||||
@app.route("/logs/aenderungen")
|
||||
@login_required
|
||||
def activity_log():
|
||||
|
||||
@@ -13,10 +13,13 @@
|
||||
--text: #eef0f4;
|
||||
--text-dim: #9aa2b4;
|
||||
--text-faint: #656d80;
|
||||
--accent: #ff7a1a;
|
||||
--accent-strong: #ff8f3d;
|
||||
--accent-dim: rgba(255, 122, 26, 0.14);
|
||||
--accent-ring: rgba(255, 122, 26, 0.35);
|
||||
/* #ff7100 = exakte Farbe aus dem WiS-Logo (static/images/logo.png,
|
||||
per Pixel-Sampling ermittelt) — Buttons/Akzente sollen genau dieses
|
||||
Orange treffen statt einer nur ähnlichen, frei gewählten Nuance. */
|
||||
--accent: #ff7100;
|
||||
--accent-strong: #ff8d33;
|
||||
--accent-dim: rgba(255, 113, 0, 0.14);
|
||||
--accent-ring: rgba(255, 113, 0, 0.35);
|
||||
--success: #2fd07a;
|
||||
--success-dim: rgba(47, 208, 122, 0.14);
|
||||
--danger: #f0475c;
|
||||
@@ -48,7 +51,7 @@
|
||||
--text: #1a1d24;
|
||||
--text-dim: #565d6d;
|
||||
--text-faint: #8a90a0;
|
||||
--accent-dim: rgba(255, 122, 26, 0.1);
|
||||
--accent-dim: rgba(255, 113, 0, 0.1);
|
||||
--muted-dim: rgba(107, 114, 128, 0.12);
|
||||
--shadow: 0 10px 30px -14px rgba(20, 20, 30, 0.18);
|
||||
--shadow-sm: 0 4px 14px -8px rgba(20, 20, 30, 0.14);
|
||||
@@ -358,7 +361,6 @@ button { font-family: inherit; }
|
||||
|
||||
.content {
|
||||
padding: 24px 28px 48px;
|
||||
max-width: 1320px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -387,7 +389,7 @@ button { font-family: inherit; }
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #1a0e03;
|
||||
box-shadow: 0 6px 18px -6px rgba(255, 122, 26, 0.55);
|
||||
box-shadow: 0 6px 18px -6px rgba(255, 113, 0, 0.55);
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-strong); }
|
||||
|
||||
@@ -471,7 +473,12 @@ button { font-family: inherit; }
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.stat-card:hover { background: var(--bg-card-hover); }
|
||||
.stat-card.active { border-color: var(--accent); background: var(--accent-dim); }
|
||||
.stat-card .stat-label { font-size: 12px; color: var(--text-faint); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.stat-card .stat-value { font-size: 26px; font-weight: 700; margin-top: 6px; letter-spacing: -0.02em; }
|
||||
.stat-card .stat-value.online { color: var(--success); }
|
||||
@@ -581,6 +588,8 @@ button { font-family: inherit; }
|
||||
@media (min-width: 720px) { .device-grid { grid-template-columns: repeat(4, 1fr); } }
|
||||
@media (min-width: 960px) { .device-grid { grid-template-columns: repeat(5, 1fr); } }
|
||||
@media (min-width: 1200px) { .device-grid { grid-template-columns: repeat(6, 1fr); } }
|
||||
@media (min-width: 1600px) { .device-grid { grid-template-columns: repeat(8, 1fr); } }
|
||||
@media (min-width: 2000px) { .device-grid { grid-template-columns: repeat(10, 1fr); } }
|
||||
|
||||
.dash-section { margin-bottom: 26px; }
|
||||
.dash-section-title {
|
||||
@@ -593,7 +602,18 @@ button { font-family: inherit; }
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
width: fit-content;
|
||||
}
|
||||
.dash-section-title:hover { color: var(--text); }
|
||||
.dash-section-title .chevron {
|
||||
width: 14px; height: 14px;
|
||||
transition: transform 0.15s ease;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.dash-section.collapsed .dash-section-title .chevron { transform: rotate(-90deg); }
|
||||
.dash-section.collapsed .device-grid { display: none; }
|
||||
|
||||
.device-card {
|
||||
position: relative;
|
||||
@@ -994,8 +1014,8 @@ select {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background:
|
||||
radial-gradient(circle at 15% 15%, rgba(255, 122, 26, 0.14), transparent 45%),
|
||||
radial-gradient(circle at 85% 85%, rgba(255, 122, 26, 0.08), transparent 40%),
|
||||
radial-gradient(circle at 15% 15%, rgba(255, 113, 0, 0.14), transparent 45%),
|
||||
radial-gradient(circle at 85% 85%, rgba(255, 113, 0, 0.08), transparent 40%),
|
||||
var(--bg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@@ -5,21 +5,21 @@
|
||||
<div id="dashboard-tiles" data-last-run-ms="{{ last_run_epoch_ms or '' }}">
|
||||
|
||||
<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-value online">{{ stats.online }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card" data-status-filter="offline">
|
||||
<div class="stat-label">Offline</div>
|
||||
<div class="stat-value offline">{{ stats.offline }}</div>
|
||||
</div>
|
||||
{% 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-value disabled">{{ stats.disabled }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="stat-card">
|
||||
<div class="stat-card" data-status-filter="">
|
||||
<div class="stat-label">Gesamt</div>
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
</div>
|
||||
@@ -54,9 +54,11 @@
|
||||
|
||||
{% 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 %}
|
||||
<div class="dash-section">
|
||||
<div class="dash-section-title">Offline <span class="pill offline">{{ offline_devices|length }}</span></div>
|
||||
<div class="dash-section" data-status="offline">
|
||||
<div class="dash-section-title">{{ section_chevron() }}Offline <span class="pill offline">{{ offline_devices|length }}</span></div>
|
||||
<div class="device-grid">
|
||||
{% for d in offline_devices %}{{ device_tile(d) }}{% endfor %}
|
||||
</div>
|
||||
@@ -64,8 +66,8 @@
|
||||
{% endif %}
|
||||
|
||||
{% if online_devices %}
|
||||
<div class="dash-section">
|
||||
<div class="dash-section-title">Online <span class="pill online">{{ online_devices|length }}</span></div>
|
||||
<div class="dash-section" data-status="online">
|
||||
<div class="dash-section-title">{{ section_chevron() }}Online <span class="pill online">{{ online_devices|length }}</span></div>
|
||||
<div class="device-grid">
|
||||
{% for d in online_devices %}{{ device_tile(d) }}{% endfor %}
|
||||
</div>
|
||||
@@ -73,8 +75,8 @@
|
||||
{% endif %}
|
||||
|
||||
{% if disabled_devices %}
|
||||
<div class="dash-section">
|
||||
<div class="dash-section-title">Deaktiviert <span class="pill disabled">{{ disabled_devices|length }}</span></div>
|
||||
<div class="dash-section" data-status="disabled">
|
||||
<div class="dash-section-title">{{ section_chevron() }}Deaktiviert <span class="pill disabled">{{ disabled_devices|length }}</span></div>
|
||||
<div class="device-grid">
|
||||
{% for d in disabled_devices %}{{ device_tile(d) }}{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -90,16 +90,29 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const activateButton = document.getElementById("activateButton");
|
||||
const searchInput = document.getElementById("tileSearch");
|
||||
|
||||
// Suchfilter über alle Abschnitte hinweg; Abschnitt wird komplett
|
||||
// ausgeblendet, wenn keine seiner Kacheln mehr passt. Wird nach jedem
|
||||
// Live-Update erneut angewendet, damit ein aktiver Filter erhalten bleibt.
|
||||
function applySearchFilter(query) {
|
||||
const q = (query || "").trim().toLowerCase();
|
||||
// Status-Filter über die Stat-Kacheln (Online/Offline/Deaktiviert/
|
||||
// Gesamt) — null = kein Filter aktiv ("Gesamt"). Zusammen mit dem
|
||||
// Suchfilter kombiniert in applyFilters(), damit beide gleichzeitig
|
||||
// greifen können (z.B. "offline" + Namenssuche).
|
||||
let activeStatusFilter = null;
|
||||
// Welche Abschnitte (per data-status) manuell eingeklappt wurden — per
|
||||
// Klick auf den Abschnittstitel, unabhängig vom Status-Filter. Bleibt
|
||||
// über Live-Updates hinweg erhalten (siehe applyCollapseState()).
|
||||
const collapsedSections = new Set();
|
||||
|
||||
// Kombinierter Filter über alle Abschnitte hinweg: ein Abschnitt wird
|
||||
// komplett ausgeblendet, wenn er nicht zum Status-Filter passt ODER
|
||||
// keine seiner Kacheln mehr zur Namenssuche passt. Wird nach jedem
|
||||
// Live-Update erneut angewendet, damit aktive Filter erhalten bleiben
|
||||
// (die alten DOM-Knoten werden beim Austausch verworfen).
|
||||
function applyFilters() {
|
||||
const q = (searchInput ? searchInput.value : "").trim().toLowerCase();
|
||||
let anyVisible = false;
|
||||
document.querySelectorAll(".dash-section").forEach(section => {
|
||||
const statusMatches = !activeStatusFilter || section.dataset.status === activeStatusFilter;
|
||||
let sectionHasMatch = false;
|
||||
section.querySelectorAll(".device-card").forEach(card => {
|
||||
const match = card.dataset.name.toLowerCase().includes(q);
|
||||
const match = statusMatches && card.dataset.name.toLowerCase().includes(q);
|
||||
card.classList.toggle("hidden", !match);
|
||||
if (match) sectionHasMatch = true;
|
||||
});
|
||||
@@ -107,9 +120,53 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (sectionHasMatch) anyVisible = true;
|
||||
});
|
||||
const noResults = document.getElementById("noSearchResults");
|
||||
if (noResults) noResults.classList.toggle("hidden", anyVisible || q === "");
|
||||
if (noResults) noResults.classList.toggle("hidden", anyVisible || (q === "" && !activeStatusFilter));
|
||||
// Aktive Stat-Kachel synchron halten — läuft hier statt nur im
|
||||
// Klick-Handler, damit der Zustand auch nach einem Live-Update
|
||||
// (frische, neu eingefügte Stat-Kacheln) sofort wieder stimmt.
|
||||
document.querySelectorAll(".stat-card[data-status-filter]").forEach(c => {
|
||||
const isTotal = c.dataset.statusFilter === "";
|
||||
const active = activeStatusFilter === null ? isTotal : c.dataset.statusFilter === activeStatusFilter;
|
||||
c.classList.toggle("active", active);
|
||||
});
|
||||
applyCollapseState();
|
||||
}
|
||||
if (searchInput) searchInput.addEventListener("input", applyFilters);
|
||||
|
||||
// Ein-/Ausgeklappt-Zustand je Abschnitt anhand von collapsedSections
|
||||
// anwenden — separat von der Sichtbarkeit oben: "hidden" blendet den
|
||||
// ganzen Abschnitt (inkl. Titel) aus, "collapsed" versteckt nur das
|
||||
// Kachel-Raster darunter, der Titel bleibt klickbar sichtbar.
|
||||
function applyCollapseState() {
|
||||
document.querySelectorAll(".dash-section").forEach(section => {
|
||||
section.classList.toggle("collapsed", collapsedSections.has(section.dataset.status));
|
||||
});
|
||||
}
|
||||
|
||||
// Stat-Kacheln (Online/Offline/Deaktiviert/Gesamt): Klick filtert das
|
||||
// Dashboard auf genau diesen Status; erneuter Klick auf die bereits
|
||||
// aktive Kachel (oder auf "Gesamt") hebt den Filter wieder auf.
|
||||
function bindStatCards() {
|
||||
document.querySelectorAll(".stat-card[data-status-filter]").forEach(card => {
|
||||
card.addEventListener("click", function () {
|
||||
const key = this.dataset.statusFilter;
|
||||
activeStatusFilter = (!key || activeStatusFilter === key) ? null : key;
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Abschnittstitel (z.B. "Offline"): Klick klappt nur diesen Abschnitt
|
||||
// ein/aus, unabhängig vom Status-Filter oben.
|
||||
function bindSectionToggles() {
|
||||
document.querySelectorAll(".dash-section-title").forEach(title => {
|
||||
title.addEventListener("click", function () {
|
||||
const key = this.closest(".dash-section").dataset.status;
|
||||
if (collapsedSections.has(key)) collapsedSections.delete(key); else collapsedSections.add(key);
|
||||
applyCollapseState();
|
||||
});
|
||||
});
|
||||
}
|
||||
if (searchInput) searchInput.addEventListener("input", () => applySearchFilter(searchInput.value));
|
||||
|
||||
// Klick-Verhalten der Kacheln (Detail-Modal, Neustart/Aktivieren) — wird
|
||||
// nach jedem Live-Update erneut auf die frisch eingefügten Kacheln
|
||||
@@ -156,6 +213,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
}
|
||||
bindDeviceCards();
|
||||
bindStatCards();
|
||||
bindSectionToggles();
|
||||
applyFilters();
|
||||
|
||||
// Live-Update der Dashboard-Kacheln ohne vollen Seiten-Reload: tauscht
|
||||
// nur den #dashboard-tiles-Container gegen das AJAX-Partial aus (siehe
|
||||
@@ -196,7 +256,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const pill = document.getElementById("global-timer-pill");
|
||||
if (pill) pill.dataset.lastRunMs = fresh.dataset.lastRunMs || "";
|
||||
bindDeviceCards();
|
||||
if (searchInput) applySearchFilter(searchInput.value);
|
||||
bindStatCards();
|
||||
bindSectionToggles();
|
||||
applyFilters();
|
||||
scheduleNextRefresh();
|
||||
})
|
||||
.catch(() => scheduleNextRefresh(15000));
|
||||
|
||||
@@ -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="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 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>
|
||||
<h2 style="font-size:16px;">Host</h2>
|
||||
<div class="hint">Name und Zeitzone dieses Hosts.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.has_permission('settings_system.edit') %}
|
||||
@@ -52,15 +24,35 @@
|
||||
Hostname setzen
|
||||
</button>
|
||||
</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 %}
|
||||
<div class="field">
|
||||
<label>Hostname</label>
|
||||
<input type="text" value="{{ current_hostname or '' }}" disabled>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Zeitzone</label>
|
||||
<input type="text" value="{{ current_timezone or '' }}" disabled>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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>
|
||||
<h2 style="font-size:16px;">Netzwerkeinstellungen</h2>
|
||||
@@ -151,5 +143,79 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user