Compare commits

...
2 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 99d4d3cd01 Live-Log: 300-Zeilen-Tail, RAW-Popup und Log-Verlauf (History) mit eigenem Recht
- /logs und /get_log liefern nur noch die letzten 300 Zeilen (_tail_lines,
  effizientes Rueckwaerts-Lesen ohne komplette Datei einzulesen) statt der
  kompletten live.log -- behebt Ladezeit-Probleme auf groesseren Umgebungen.
- Neuer Endpoint /logs/live/raw liefert das komplette Live-Log fuer ein
  Popup im Stil des bestehenden 'Ungespeicherte Aenderungen'-Modals
  (window.openRawLogModal in app.js, lazy nachgeladen erst bei Klick).
- Neue Unterseite 'Verlauf' (/logs/history, /logs/history/raw) fuer aeltere,
  von logrotate rotierte Kopien des Live-Logs (live.log.1, .2, ...),
  auswaehlbar per Dropdown, ebenfalls mit 300-Zeilen-Tail + RAW-Popup.
- Eigenes Recht logs_history.view (Kind von logs_group, direkt unter Live
  einsortiert) statt an logs_live.view gekoppelt -- Live-Status sehen und
  durch alte Logs blaettern sind bewusst getrennte Rechte.
- Dateiname-Validierung gegen Path-Traversal in _resolve_log_history_file
  (striktes Regex + Verzeichnis-Check + Existenzpruefung).
- VERSION 1.0.4 -> 1.0.5.

Live getestet auf Testsystem-Update-Restart (192.168.82.51): Tail-Anzeige,
RAW-Popup, Verlauf-Auswahl inkl. echter live.log.1, Permission-Matrix
(Gruppen-Seite) sowie Rechtetrennung (Nutzer mit nur logs_live.view sieht
/logs, aber weder Verlauf-Link noch Zugriff auf /logs/history bzw.
/logs/history/raw) und Path-Traversal-Abwehr allesamt verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 10:53:56 +02:00
alientim c30ad41101 install.sh: Sicherheitsnetz gilt jetzt fuer JEDES Update, nicht nur Schema-Aenderungen
Live gefunden: der 'Schema unveraendert -> kein Backup'-Kurzschluss aus
v1.0.3 liess einen rein CODE-seitigen Fehler im neuen Paket (z.B. ein
kaputtes app.py -- mit dem DB-Schema hat das nichts zu tun) komplett
ungeschuetzt durch. Backup + Health-Check + ggf. Rollback laufen jetzt
bei JEDEM Update einer bestehenden Installation, unabhaengig von
SCHEMA_VERSION -- die bleibt nur noch fuer den Wortlaut der Log-Meldung
relevant (In-Place-Update vs. Migration).

Zusaetzlich beim Rollback selbst: 'mv' durch 'cp -a' beim Zurueckspielen
ersetzt, plus die fehlgeschlagene Installation vorher separat beiseite
gelegt (/srv/tesm-failed-update-<timestamp>) statt geloescht -- vorher
wurde das Backup beim Zurueckspielen selbst zum neuen /srv/tesm und
verschwand dadurch als eigenstaendige Sicherung (widersprach der
eigenen Zusage 'Backup bleibt erhalten').

Live auf der Testbox in allen drei Pfaden verifiziert:
- Erfolgreiches Update: Backup erstellt, Health-Check bestanden,
  Backup geloescht, Daten erhalten.
- Migration (kuenstlich abweichende SCHEMA_VERSION, echte Struktur
  unveraendert): Backup erstellt, Health-Check bestanden, Backup
  geloescht.
- Fehlgeschlagenes Update (absichtlich kaputte app.py): Backup
  erstellt, Health-Check schlaegt korrekt fehl, automatischer Rollback
  stellt den letzten funktionierenden Zustand wieder her (Dienst aktiv,
  Health-Check 200, Datenbank-Canary erhalten), Backup UND
  fehlgeschlagene Installation bleiben als zwei getrennte Verzeichnisse
  zur Fehlersuche erhalten.

VERSION: 1.0.4 (v1.0.3 hatte die oben beschriebene Luecke und wurde
bereits ausgerollt, daher hier ein neues Release statt eines erneuten
Tags auf v1.0.3).
2026-08-22 10:30:33 +02:00
8 changed files with 445 additions and 49 deletions
+57 -34
View File
@@ -8,23 +8,30 @@
# Erkennt selbstständig, ob unter /srv/tesm bereits eine Installation # Erkennt selbstständig, ob unter /srv/tesm bereits eine Installation
# existiert (an sqlite.db), und verhält sich entsprechend: # existiert (an sqlite.db), und verhält sich entsprechend:
# #
# - Keine vorhandene Installation -> normale Frischinstallation. # - Keine vorhandene Installation -> normale Frischinstallation, kein
# - Vorhanden, SCHEMA_VERSION unverändert -> einfaches In-Place-Update # Backup nötig (nichts, worauf zurückgerollt werden könnte).
# (Code/Templates/Abhängigkeiten aktualisieren; sqlite.db bleibt # - Vorhanden (egal ob SCHEMA_VERSION gleich geblieben oder sich
# unangetastet -- rsync schließt sie ohnehin aus). Kein Backup nötig, # geändert hat) -> Update MIT Sicherheitsnetz: die komplette bisherige
# da sich am Datenbank-Schema nichts ändert. # Installation (Code, venv, Datenbank, Schlüssel) wird vorher 1:1 nach
# - Vorhanden, SCHEMA_VERSION unbekannt oder abweichend -> Update MIT # /srv/tesm-backup-pre-update-<timestamp> kopiert. Eine eigentliche
# Sicherheitsnetz: die komplette bisherige Installation (Code, venv, # Schema-Migration übernimmt danach die App selbst beim Start (siehe
# Datenbank, Schlüssel) wird vorher 1:1 nach /srv/tesm-backup-pre- # _ensure_schema() in app.py). Kommt der Dienst anschließend
# migration-<timestamp> kopiert. Die eigentliche Schema-Migration # nachweislich NICHT gesund hoch (HTTP-Check auf /login), wird
# übernimmt danach die App selbst beim Start (siehe _ensure_schema() # automatisch die komplette Sicherung zurückgespielt (alter Code +
# in app.py). Kommt der Dienst anschließend nachweislich NICHT gesund # alte Datenbank zusammen -- ein reines DB-Rollback allein würde
# hoch (HTTP-Check auf /login), wird automatisch die komplette # denselben Fehler beim nächsten Start mit dem neuen Code sofort
# Sicherung zurückgespielt (alter Code + alte Datenbank zusammen -- # wiederholen) und das Backup bleibt zur manuellen Prüfung erhalten.
# ein reines DB-Rollback allein würde denselben Migrationsfehler beim # Kommt er gesund hoch, gilt das Update als erfolgreich und das
# nächsten Start mit dem neuen Code sofort wiederholen) und das # Backup wird gelöscht.
# Backup bleibt zur manuellen Prüfung erhalten. Kommt er gesund hoch, #
# gilt die Migration als erfolgreich und das Backup wird gelöscht. # Das Sicherheitsnetz greift bewusst bei JEDEM Update einer bestehenden
# Installation, nicht nur bei einer erkannten Schema-Änderung -- ein
# rein code-seitiger Fehler im neuen Paket hat mit dem Datenbank-Schema
# nichts zu tun, hätte den Dienst bei einem "Schema unverändert -> kein
# Backup"-Kurzschluss aber trotzdem ungeschützt lahmgelegt (live
# reproduziert). SCHEMA_VERSION bestimmt nur noch den Wortlaut der
# Log-Meldung (In-Place-Update vs. Migration), nicht mehr, OB
# abgesichert wird.
# ============================================================================ # ============================================================================
set -e set -e
@@ -46,6 +53,15 @@ step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/tesm-install
clear 2>/dev/null || true clear 2>/dev/null || true
# ---- Bestehende Installation erkennen ---- # ---- Bestehende Installation erkennen ----
# WICHTIG: das Sicherheitsnetz (Backup + Health-Check + ggf. Rückroll)
# greift bei JEDEM Update einer bestehenden Installation, nicht nur bei
# einer erkannten Schema-Änderung -- live so gefunden: ein rein
# code-seitiger Fehler im neuen Paket (z.B. ein kaputtes app.py, mit dem
# Schema selbst hat das nichts zu tun) hätte bei einem reinen "Schema
# unverändert -> kein Backup"-Kurzschluss ungeschützt den Dienst
# lahmgelegt, ganz ohne automatische Wiederherstellung. Die
# SCHEMA_VERSION-Auswertung dient jetzt nur noch der Log-Meldung (WARUM
# aktualisiert wird), nicht mehr der Entscheidung OB abgesichert wird.
EXISTING_INSTALL=0 EXISTING_INSTALL=0
[ -f /srv/tesm/sqlite.db ] && EXISTING_INSTALL=1 [ -f /srv/tesm/sqlite.db ] && EXISTING_INSTALL=1
@@ -63,7 +79,7 @@ if [ "$EXISTING_INSTALL" -eq 1 ]; then
print_status "Service stopped" print_status "Service stopped"
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ] && [ "$OLD_SCHEMA_VERSION" == "$NEW_SCHEMA_VERSION" ]; then if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ] && [ "$OLD_SCHEMA_VERSION" == "$NEW_SCHEMA_VERSION" ]; then
echo -e "${GREEN}Bestehende Installation erkannt, Datenbank-Schema unverändert (Version ${OLD_SCHEMA_VERSION}):${NC} einfaches In-Place-Update, kein Backup nötig." echo -e "${GREEN}Bestehende Installation erkannt, Datenbank-Schema unverändert (Version ${OLD_SCHEMA_VERSION}):${NC} In-Place-Update."
else else
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ]; then if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ]; then
reason="Datenbank-Schema hat sich geändert (${OLD_SCHEMA_VERSION} -> ${NEW_SCHEMA_VERSION})" reason="Datenbank-Schema hat sich geändert (${OLD_SCHEMA_VERSION} -> ${NEW_SCHEMA_VERSION})"
@@ -71,13 +87,13 @@ if [ "$EXISTING_INSTALL" -eq 1 ]; then
reason="Schema-Version der bestehenden Installation oder dieses Pakets unbekannt" reason="Schema-Version der bestehenden Installation oder dieses Pakets unbekannt"
fi fi
echo -e "${YELLOW}Bestehende Installation erkannt, ${reason}:${NC}" echo -e "${YELLOW}Bestehende Installation erkannt, ${reason}:${NC}"
echo "Update mit automatischer Migration und Sicherheitsnetz (Rückroll-Backup)." echo "Update mit automatischer Migration."
NEEDS_MIGRATION_GUARD=1
BACKUP_DIR="/srv/tesm-backup-pre-migration-$(date +%Y%m%d-%H%M%S)"
step "Backing up current installation to $BACKUP_DIR before migration"
cp -a /srv/tesm "$BACKUP_DIR"
print_status "Backup created"
fi fi
NEEDS_MIGRATION_GUARD=1
BACKUP_DIR="/srv/tesm-backup-pre-update-$(date +%Y%m%d-%H%M%S)"
step "Backing up current installation to $BACKUP_DIR before update"
cp -a /srv/tesm "$BACKUP_DIR"
print_status "Backup created"
fi fi
# ---- Pakete ---- # ---- Pakete ----
@@ -164,11 +180,10 @@ sudo systemctl enable --now tesm.service
sudo systemctl enable --now tesm-check.service tesm-check-restart.timer sudo systemctl enable --now tesm-check.service tesm-check-restart.timer
print_status "Services enabled and started" print_status "Services enabled and started"
# ---- Migrations-Sicherheitsnetz prüfen ---- # ---- Sicherheitsnetz prüfen ----
# Nur relevant, wenn oben tatsächlich eine riskante Migration (abweichende/ # Läuft bei JEDEM Update einer bestehenden Installation (NEEDS_MIGRATION_
# unbekannte SCHEMA_VERSION) erkannt wurde -- beim einfachen In-Place-Update # GUARD=1, siehe oben) -- nur bei einer Frischinstallation ist BACKUP_DIR
# oder einer Frischinstallation ist BACKUP_DIR leer und dieser Block wird # leer und dieser Block wird komplett übersprungen.
# komplett übersprungen.
if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then
step "Verifying migrated installation" step "Verifying migrated installation"
HEALTHY=0 HEALTHY=0
@@ -182,15 +197,23 @@ if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then
if [ "$HEALTHY" -eq 1 ]; then if [ "$HEALTHY" -eq 1 ]; then
print_status "Migration verified healthy" print_status "Migration verified healthy"
rm -rf "$BACKUP_DIR" rm -rf "$BACKUP_DIR"
echo -e "${GREEN}${NC} Migration erfolgreich -- Backup wieder entfernt." echo -e "${GREEN}${NC} Update erfolgreich -- Backup wieder entfernt."
else else
echo -e "${RED}Migration fehlgeschlagen -- Dienst antwortet nicht gesund. Rolle zurück...${NC}" echo -e "${RED}Update fehlgeschlagen -- Dienst antwortet nicht gesund. Rolle zurück...${NC}"
sudo systemctl stop tesm.service 2>/dev/null || true sudo systemctl stop tesm.service 2>/dev/null || true
sudo rm -rf /srv/tesm # Fehlgeschlagene Installation NICHT einfach löschen, sondern separat
sudo mv "$BACKUP_DIR" /srv/tesm # beiseite legen (zur Fehlersuche) -- und das Backup per "cp" statt
# "mv" zurückspielen, damit BEIDES danach getrennt erhalten bleibt
# (sonst würde das Backup beim Zurückspielen selbst zum neuen
# /srv/tesm werden und als eigenständige Sicherung verschwinden).
FAILED_DIR="/srv/tesm-failed-update-$(date +%Y%m%d-%H%M%S)"
sudo mv /srv/tesm "$FAILED_DIR"
sudo cp -a "$BACKUP_DIR" /srv/tesm
sudo systemctl start tesm.service sudo systemctl start tesm.service
echo -e "${YELLOW}Zurückgerollt auf den Stand vor dem Update (alter Code + alte Datenbank zusammen).${NC}" echo -e "${YELLOW}Zurückgerollt auf den Stand vor dem Update (alter Code + alte Datenbank zusammen).${NC}"
echo "Bitte /var/log/tesm/app.log prüfen oder Export/Import nutzen, bevor erneut aktualisiert wird." echo "Backup bleibt erhalten unter: ${BACKUP_DIR}"
echo "Die fehlgeschlagene Installation liegt zur Fehlersuche unter: ${FAILED_DIR}"
echo "Bitte /var/log/tesm/app.log dort prüfen, bevor erneut aktualisiert wird."
fi fi
fi fi
+1 -1
View File
@@ -1 +1 @@
1.0.3 1.0.5
+210 -7
View File
@@ -209,6 +209,7 @@ PERMISSIONS = {
"view_key": "logs_group.view", "view_key": "logs_group.view",
"children": { "children": {
"logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}}, "logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}},
"logs_history": {"label": "Verlauf", "rows": {"view": "logs_history.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"}}, "logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}},
}, },
@@ -278,6 +279,7 @@ PERMISSION_LABELS = {
"papierkorb.edit": "Papierkorb bearbeiten (beliebige Einträge wiederherstellen oder endgültig löschen, unabhängig vom sonstigen Bearbeiten-Recht der jeweiligen Ressource)", "papierkorb.edit": "Papierkorb bearbeiten (beliebige Einträge wiederherstellen oder endgültig löschen, unabhängig vom sonstigen Bearbeiten-Recht der jeweiligen Ressource)",
"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", "logs_kea.view": "Kea-DHCP-Log lesen",
"logs_history.view": "Log-Verlauf lesen (ältere, durch logrotate rotierte Kopien des Live-Logs)",
"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",
@@ -333,6 +335,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_history", "label": "Verlauf", "icon": "clock", "endpoint": "logs_history"},
{"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"}, {"key": "logs_kea", "label": "Kea-DHCP", "icon": "network", "endpoint": "kea_log"},
]}, ]},
@@ -368,6 +371,8 @@ def _nav_key_visible(key, user):
return user.can_view_settings_ldap return user.can_view_settings_ldap
if key == "logs_live": if key == "logs_live":
return user.can_view_live_log return user.can_view_live_log
if key == "logs_history":
return user.can_view_log_history
if key == "logs_activity": if key == "logs_activity":
return user.can_view_activity_log return user.can_view_activity_log
if key == "logs_kea": if key == "logs_kea":
@@ -552,6 +557,14 @@ class User(UserMixin):
def can_view_live_log(self): def can_view_live_log(self):
return self.has_permission("logs_live.view") return self.has_permission("logs_live.view")
@property
def can_view_log_history(self):
"""Ältere, durch logrotate rotierte Kopien des Live-Logs (Seite
Verlauf) -- bewusst eigenes Recht statt an logs_live.view gekoppelt,
damit z.B. ein Nutzer den aktuellen Live-Status sehen darf, ohne
automatisch auch durch ältere Logstände blättern zu können."""
return self.has_permission("logs_history.view")
@property @property
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")
@@ -1828,6 +1841,106 @@ def _latest_log_file():
return TESM_LIVE_LOG_PATH if os.path.exists(TESM_LIVE_LOG_PATH) else None return TESM_LIVE_LOG_PATH if os.path.exists(TESM_LIVE_LOG_PATH) else None
LIVE_LOG_TAIL_LINES = 300
def _tail_lines(path, n=LIVE_LOG_TAIL_LINES, chunk_size=65536):
"""Liest effizient nur die letzten n Zeilen einer Datei, ohne sie
komplett einzulesen -- sucht dazu vom Dateiende rückwärts in Blöcken,
bis n Zeilenumbrüche gefunden wurden oder der Dateianfang erreicht ist.
Auf größeren Umgebungen (viele Geräte, kurzes Prüfintervall) kann
live.log zwischen zwei logrotate-Läufen mehrere MB groß werden; ein
komplettes Einlesen bei jedem Seitenaufruf/Live-Poll hat dort spürbare
Ladezeiten verursacht -- das war der eigentliche Auslöser dieser
Funktion."""
with open(path, "rb") as f:
f.seek(0, os.SEEK_END)
remaining = f.tell()
data = b""
while remaining > 0 and data.count(b"\n") <= n:
read_size = min(chunk_size, remaining)
remaining -= read_size
f.seek(remaining)
data = f.read(read_size) + data
text = data.decode("utf-8", errors="replace")
lines = text.split("\n")
return "\n".join(lines[-n:]) if len(lines) > n else text
def _count_lines(path):
"""Schnelle Zeilenzählung über rohe Byte-Chunks (nur Zählen von \\n,
kein Aufbau einer Python-Zeilenliste) -- für die "letzte n von insgesamt
X Zeilen"-Anzeige, ohne den Performance-Vorteil von _tail_lines wieder
durch ein volles Einlesen zunichte zu machen."""
try:
count = 0
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
count += chunk.count(b"\n")
return count
except OSError:
return None
def _format_log_size(num_bytes):
size = float(num_bytes)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} GB"
_LOG_HISTORY_FILE_RE = re.compile(r"^live\.log\.([1-9][0-9]*)$")
def _live_log_history_files():
"""Ältere, von logrotate rotierte Kopien des Live-Logs (live.log.1 =
zuletzt rotiert, .2 der Lauf davor, usw. -- copytruncate ohne compress,
siehe _write_logrotate_config, daher reine Textdateien ohne .gz),
sortiert von neu (1) nach alt."""
results = []
try:
entries = os.listdir(TESM_LOG_DIR)
except OSError:
return results
for entry in entries:
m = _LOG_HISTORY_FILE_RE.match(entry)
if not m:
continue
full_path = os.path.join(TESM_LOG_DIR, entry)
if not os.path.isfile(full_path):
continue
try:
mtime = os.path.getmtime(full_path)
size = os.path.getsize(full_path)
except OSError:
continue
results.append({
"filename": entry,
"generation": int(m.group(1)),
"mtime_str": datetime.fromtimestamp(mtime).strftime("%d.%m.%Y %H:%M"),
"size_str": _format_log_size(size),
})
results.sort(key=lambda r: r["generation"])
return results
def _resolve_log_history_file(filename):
"""Validiert einen vom Client übergebenen Dateinamen (?file=...) gegen
die tatsächlich vorhandenen rotierten Live-Log-Kopien in TESM_LOG_DIR --
verhindert Path-Traversal bzw. beliebiges Datei-Lesen über den
Query-Parameter. Gibt den vollen Pfad oder None zurück."""
if not filename or not _LOG_HISTORY_FILE_RE.match(filename):
return None
full_path = os.path.join(TESM_LOG_DIR, filename)
if os.path.dirname(os.path.abspath(full_path)) != os.path.abspath(TESM_LOG_DIR):
return None
if not os.path.isfile(full_path):
return None
return full_path
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. Sucht """Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war. Sucht
bewusst auch in den von logrotate rotierten Kopien (live.log.1, .2, ), bewusst auch in den von logrotate rotierten Kopien (live.log.1, .2, ),
@@ -5711,21 +5824,106 @@ def _run_ssh_reboot(host, port, username, password, timeout=15):
@app.route("/get_log") @app.route("/get_log")
@login_required @login_required
def get_log(): def get_log():
"""Wird vom Live-Log-Poll (Intervall-Timer + manueller Aktualisieren-
Button) aufgerufen -- liefert bewusst nur die letzten LIVE_LOG_TAIL_LINES
Zeilen statt der kompletten Datei (siehe _tail_lines), damit dieser
wiederkehrende Abruf auf größeren Umgebungen keine spürbaren Ladezeiten
mehr verursacht. Das komplette Log bleibt über get_log_raw erreichbar."""
if not current_user.can_view_live_log: if not current_user.can_view_live_log:
return "Keine Berechtigung.", 403 return "Keine Berechtigung.", 403
latest_log = _latest_log_file() latest_log = _latest_log_file()
if not latest_log: if not latest_log:
return "Keine Logfiles gefunden." return "Keine Logfiles gefunden."
try: try:
with open(latest_log, "r") as f: content = _tail_lines(latest_log)
total_lines = _count_lines(latest_log)
except OSError as e:
content = f"Fehler beim Lesen des Logs: {e}"
total_lines = None
response = app.response_class(content, mimetype="text/plain")
response.headers["X-Log-Name"] = os.path.basename(latest_log)
if total_lines is not None:
response.headers["X-Total-Lines"] = str(total_lines)
return response
@app.route("/logs/live/raw")
@login_required
def get_log_raw():
"""Komplettes, unbearbeitetes Live-Log für das RAW-Popup -- bewusst ein
eigener Endpoint statt eines Query-Parameters an /get_log, damit der
normale Live-Poll (jeder Nutzer mit logs_live.view, in jedem
Prüfintervall) unter keinen Umständen versehentlich die komplette Datei
lädt. Wird nur einmalig auf explizite Nutzeraktion (Button-Klick)
nachgeladen."""
if not current_user.can_view_live_log:
return "Keine Berechtigung.", 403
latest_log = _latest_log_file()
if not latest_log:
return "Keine Logfiles gefunden."
try:
with open(latest_log, "r", encoding="utf-8", errors="replace") as f:
content = f.read() content = f.read()
except Exception as e: except OSError as e:
content = f"Fehler beim Lesen des Logs: {e}" content = f"Fehler beim Lesen des Logs: {e}"
response = app.response_class(content, mimetype="text/plain") response = app.response_class(content, mimetype="text/plain")
response.headers["X-Log-Name"] = os.path.basename(latest_log) response.headers["X-Log-Name"] = os.path.basename(latest_log)
return response return response
@app.route("/logs/history")
@login_required
def logs_history():
"""Verlauf: ältere, von logrotate rotierte Kopien des Live-Logs, als
eigene Unterseite von Live erreichbar (eigenes Recht logs_history.view,
siehe User.can_view_log_history) -- bewusst getrennt vom laufenden
Live-Poll, damit das Durchblättern alter Logstände nicht denselben
Performance-Pfad wie die Live-Ansicht belastet."""
if not current_user.can_view_log_history:
flash("Keine Berechtigung, den Log-Verlauf einzusehen.", "danger")
return redirect(url_for("index"))
files = _live_log_history_files()
requested = request.args.get("file", "")
selected_path = _resolve_log_history_file(requested) if requested else None
selected_name = requested if selected_path else None
if not selected_path and files:
selected_name = files[0]["filename"]
selected_path = os.path.join(TESM_LOG_DIR, selected_name)
log_content = None
total_lines = None
if selected_path:
try:
log_content = _tail_lines(selected_path)
total_lines = _count_lines(selected_path)
except OSError as e:
log_content = f"Fehler beim Lesen des Logs: {e}"
return render_template(
"logs_history.html", files=files, selected_name=selected_name,
log_content=log_content, total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES,
)
@app.route("/logs/history/raw")
@login_required
def logs_history_raw():
if not current_user.can_view_log_history:
return "Keine Berechtigung.", 403
path = _resolve_log_history_file(request.args.get("file", ""))
if not path:
return "Unbekannte oder ungültige Log-Datei.", 404
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError as e:
content = f"Fehler beim Lesen des Logs: {e}"
response = app.response_class(content, mimetype="text/plain")
response.headers["X-Log-Name"] = os.path.basename(path)
return response
@app.route("/logs") @app.route("/logs")
@login_required @login_required
def logs(): def logs():
@@ -5736,16 +5934,21 @@ def logs():
latest_log = _latest_log_file() latest_log = _latest_log_file()
if not latest_log: if not latest_log:
return render_template("logs.html", log_content="Keine Logfiles gefunden.", interval=interval) return render_template(
"logs.html", log_content="Keine Logfiles gefunden.", interval=interval,
total_lines=None, tail_lines=LIVE_LOG_TAIL_LINES,
)
try: try:
with open(latest_log, "r") as f: log_content = _tail_lines(latest_log)
log_content = f.read() total_lines = _count_lines(latest_log)
except Exception as e: except OSError as e:
log_content = f"Fehler beim Lesen des Logs: {e}" log_content = f"Fehler beim Lesen des Logs: {e}"
total_lines = None
return render_template( return render_template(
"logs.html", log_content=log_content, log_name=os.path.basename(latest_log), interval=interval "logs.html", log_content=log_content, log_name=os.path.basename(latest_log), interval=interval,
total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES,
) )
+15
View File
@@ -1104,6 +1104,21 @@ select {
.log-line.sep { color: #3a4050; } .log-line.sep { color: #3a4050; }
.log-line.restart { color: #ffb454; } .log-line.restart { color: #ffb454; }
.raw-log-content {
flex: 1;
overflow: auto;
margin: 0;
padding: 14px 18px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
color: #c7ccd6;
background: #0a0c10;
}
[data-theme="light"] .raw-log-content { background: #0e1116; }
/* ========================================================================== /* ==========================================================================
Permission checklists (Gruppen) Permission checklists (Gruppen)
========================================================================== */ ========================================================================== */
+53
View File
@@ -211,6 +211,59 @@
openModal("save-discard-modal"); openModal("save-discard-modal");
}; };
/* ---------------- RAW-Log-Popup (Live-Log & Verlauf) ---------------- */
/* Gleiches Modal-Grundgerüst wie der Ja/Nein-Dialog für ungespeicherte
Änderungen (modal-overlay/modal-header/modal-body/modal-footer), nur
breiter/höher und mit lazy nachgeladenem Inhalt -- der komplette
Log-Inhalt wird bewusst erst beim tatsächlichen Öffnen per fetch
nachgeladen, nicht schon beim Seitenaufbau, damit genau das Problem
(unnötig große Ladezeiten) nicht durch das Popup selbst zurückkommt. */
function ensureRawLogModal() {
if (document.getElementById("raw-log-modal")) return;
const html = `
<div class="modal-overlay" id="raw-log-modal">
<div class="modal" style="max-width:min(1100px, 92vw); height:85vh;">
<div class="modal-header">
<h3 id="raw-log-modal-title">Komplettes Log (RAW)</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body" style="padding:0; display:flex; flex-direction:column; flex:1; min-height:0;">
<pre id="raw-log-modal-content" class="raw-log-content"></pre>
</div>
<div class="modal-footer">
<span class="text-faint mono" id="raw-log-modal-meta" style="margin-right:auto; font-size:11.5px;"></span>
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#raw-log-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("raw-log-modal").addEventListener("click", (e) => {
if (e.target.id === "raw-log-modal") closeModal(e.target);
});
}
window.openRawLogModal = function (url, title) {
if (!url) return;
ensureRawLogModal();
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
const content = document.getElementById("raw-log-modal-content");
const meta = document.getElementById("raw-log-modal-meta");
content.textContent = "Lade …";
meta.textContent = "";
openModal("raw-log-modal");
fetch(url)
.then((r) => {
const logName = r.headers.get("X-Log-Name");
if (logName) meta.textContent = logName;
return r.text();
})
.then((text) => { content.textContent = text; })
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
};
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */ /* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten /* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/ Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
+1
View File
@@ -19,6 +19,7 @@
"key": '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>', "key": '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>', "terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>',
"history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>', "history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
"clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>',
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>', "sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>', "logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>', "gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
+24 -7
View File
@@ -8,18 +8,31 @@
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 style="font-size:16px;">Live-Log</h2> <h2 style="font-size:16px;">Live-Log</h2>
<div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline).</div> <div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline). Zeigt aus Performance-Gründen nur die letzten {{ tail_lines }} Zeilen.</div>
</div>
<div class="flex gap-2">
{% if current_user.can_view_log_history %}
<a href="{{ url_for('logs_history') }}" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>
Verlauf
</a>
{% endif %}
<button type="button" class="btn btn-secondary" onclick="openRawLogModal('{{ url_for('get_log_raw') }}', 'Komplettes Live-Log (RAW)')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
Komplettes Log (RAW)
</button>
<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>
<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>
<div class="log-shell"> <div class="log-shell">
<div class="log-toolbar"> <div class="log-toolbar">
<div class="log-dots"><span></span><span></span><span></span></div> <div class="log-dots"><span></span><span></span><span></span></div>
<span class="text-faint mono" style="font-size:11.5px;" data-log-name>{{ log_name or "" }}</span> <span class="text-faint mono" style="font-size:11.5px;" data-log-name>{{ log_name or "" }}</span>
<span class="text-faint mono" style="font-size:11.5px;" id="log-line-info">{% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen{% endif %}</span>
</div> </div>
<div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div> <div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
</div> </div>
@@ -43,7 +56,7 @@ document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ global_check_interval | int }}; const intervalMinutes = {{ global_check_interval | int }};
const intervalMilliseconds = intervalMinutes * 60 * 1000; const intervalMilliseconds = intervalMinutes * 60 * 1000;
function renderLog(text, logName) { function renderLog(text, logName, totalLines) {
const box = document.getElementById("log-box"); const box = document.getElementById("log-box");
box.innerHTML = ""; box.innerHTML = "";
const lines = text.split("\n"); const lines = text.split("\n");
@@ -55,11 +68,15 @@ document.addEventListener("DOMContentLoaded", () => {
if (logName) { if (logName) {
document.querySelectorAll("[data-log-name]").forEach((el) => { el.textContent = logName; }); document.querySelectorAll("[data-log-name]").forEach((el) => { el.textContent = logName; });
} }
const info = document.getElementById("log-line-info");
if (info && totalLines) {
info.textContent = "letzte {{ tail_lines }} von " + totalLines + " Zeilen";
}
} }
function fetchLog() { function fetchLog() {
fetch("{{ url_for('get_log') }}") fetch("{{ url_for('get_log') }}")
.then(r => r.text().then((text) => renderLog(text, r.headers.get("X-Log-Name")))) .then(r => r.text().then((text) => renderLog(text, r.headers.get("X-Log-Name"), r.headers.get("X-Total-Lines"))))
.catch(err => console.error(err)); .catch(err => console.error(err));
} }
+84
View File
@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% set active_page = "logs" %}
{% block page_title %}Verlauf{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ selected_name or "kein Archiv-Log" }}</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Log-Verlauf</h2>
<div class="hint">Ältere, von logrotate rotierte Kopien des Live-Logs — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar.</div>
</div>
<div class="flex gap-2">
<a href="{{ url_for('logs') }}" 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="M19 12H5M12 19l-7-7 7-7"/></svg>
Zurück zur Live-Ansicht
</a>
<button type="button" class="btn btn-secondary"
onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_name or '' }}')"
{% if not selected_name %}disabled{% endif %}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
Komplettes Log (RAW)
</button>
</div>
</div>
{% if not files %}
<p class="text-faint" style="font-size:12.5px;">
Noch keine rotierten Live-Logs vorhanden — sobald logrotate das aktuelle
<a href="{{ url_for('logs') }}" style="color:var(--accent-strong); font-weight:600;">Live-Log</a>
zum ersten Mal rotiert (siehe Rotations-Intervall unter Systemeinstellungen), erscheinen ältere Stände hier.
</p>
{% else %}
<div class="flex gap-2" style="align-items:center; margin-bottom:14px;">
<label for="history-file-select" class="text-faint" style="font-size:12.5px; font-weight:600;">Archiv-Stand:</label>
<select id="history-file-select" onchange="window.location.href=this.value;" class="mono" style="max-width:420px;">
{% for f in files %}
<option value="{{ url_for('logs_history', file=f.filename) }}" {% if f.filename == selected_name %}selected{% endif %}>
{{ f.filename }} — rotiert am {{ f.mtime_str }} ({{ f.size_str }})
</option>
{% endfor %}
</select>
</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;">{{ selected_name }}</span>
<span class="text-faint mono" style="font-size:11.5px;">{% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen{% endif %}</span>
</div>
<div id="log-box">{{ log_content or "" }}</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function colorizeLine(line) {
let cls = "";
if (line.includes(" ist erreichbar!")) cls = "online";
else if (line.includes(" ist nicht erreichbar!")) cls = "offline";
else if (line.startsWith("----")) cls = "sep";
else if (line.toLowerCase().includes("manuell") || line.includes("PoE")) cls = "restart";
const span = document.createElement("span");
span.className = "log-line" + (cls ? " " + cls : "");
span.textContent = line;
return span;
}
document.addEventListener("DOMContentLoaded", () => {
const box = document.getElementById("log-box");
if (!box) return;
const text = box.textContent;
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;
});
</script>
{% endblock %}