Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99d4d3cd01 | ||
|
|
c30ad41101 |
+57
-34
@@ -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
@@ -1 +1 @@
|
|||||||
1.0.3
|
1.0.5
|
||||||
|
|||||||
+210
-7
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|||||||
@@ -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>×</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/
|
||||||
|
|||||||
@@ -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"/>',
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user