Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99d4d3cd01 | ||
|
|
c30ad41101 |
+57
-34
@@ -8,23 +8,30 @@
|
||||
# Erkennt selbstständig, ob unter /srv/tesm bereits eine Installation
|
||||
# existiert (an sqlite.db), und verhält sich entsprechend:
|
||||
#
|
||||
# - Keine vorhandene Installation -> normale Frischinstallation.
|
||||
# - Vorhanden, SCHEMA_VERSION unverändert -> einfaches In-Place-Update
|
||||
# (Code/Templates/Abhängigkeiten aktualisieren; sqlite.db bleibt
|
||||
# unangetastet -- rsync schließt sie ohnehin aus). Kein Backup nötig,
|
||||
# da sich am Datenbank-Schema nichts ändert.
|
||||
# - Vorhanden, SCHEMA_VERSION unbekannt oder abweichend -> Update MIT
|
||||
# Sicherheitsnetz: die komplette bisherige Installation (Code, venv,
|
||||
# Datenbank, Schlüssel) wird vorher 1:1 nach /srv/tesm-backup-pre-
|
||||
# migration-<timestamp> kopiert. Die eigentliche Schema-Migration
|
||||
# übernimmt danach die App selbst beim Start (siehe _ensure_schema()
|
||||
# in app.py). Kommt der Dienst anschließend nachweislich NICHT gesund
|
||||
# hoch (HTTP-Check auf /login), wird automatisch die komplette
|
||||
# Sicherung zurückgespielt (alter Code + alte Datenbank zusammen --
|
||||
# ein reines DB-Rollback allein würde denselben Migrationsfehler beim
|
||||
# nächsten Start mit dem neuen Code sofort wiederholen) und das
|
||||
# Backup bleibt zur manuellen Prüfung erhalten. Kommt er gesund hoch,
|
||||
# gilt die Migration als erfolgreich und das Backup wird gelöscht.
|
||||
# - Keine vorhandene Installation -> normale Frischinstallation, kein
|
||||
# Backup nötig (nichts, worauf zurückgerollt werden könnte).
|
||||
# - Vorhanden (egal ob SCHEMA_VERSION gleich geblieben oder sich
|
||||
# geändert hat) -> Update MIT Sicherheitsnetz: die komplette bisherige
|
||||
# Installation (Code, venv, Datenbank, Schlüssel) wird vorher 1:1 nach
|
||||
# /srv/tesm-backup-pre-update-<timestamp> kopiert. Eine eigentliche
|
||||
# Schema-Migration übernimmt danach die App selbst beim Start (siehe
|
||||
# _ensure_schema() in app.py). Kommt der Dienst anschließend
|
||||
# nachweislich NICHT gesund hoch (HTTP-Check auf /login), wird
|
||||
# automatisch die komplette Sicherung zurückgespielt (alter Code +
|
||||
# alte Datenbank zusammen -- ein reines DB-Rollback allein würde
|
||||
# denselben Fehler beim nächsten Start mit dem neuen Code sofort
|
||||
# wiederholen) und das Backup bleibt zur manuellen Prüfung erhalten.
|
||||
# Kommt er gesund hoch, gilt das Update 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
|
||||
|
||||
@@ -46,6 +53,15 @@ step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/tesm-install
|
||||
clear 2>/dev/null || true
|
||||
|
||||
# ---- 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
|
||||
[ -f /srv/tesm/sqlite.db ] && EXISTING_INSTALL=1
|
||||
|
||||
@@ -63,7 +79,7 @@ if [ "$EXISTING_INSTALL" -eq 1 ]; then
|
||||
print_status "Service stopped"
|
||||
|
||||
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
|
||||
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ]; then
|
||||
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"
|
||||
fi
|
||||
echo -e "${YELLOW}Bestehende Installation erkannt, ${reason}:${NC}"
|
||||
echo "Update mit automatischer Migration und Sicherheitsnetz (Rückroll-Backup)."
|
||||
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"
|
||||
echo "Update mit automatischer Migration."
|
||||
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
|
||||
|
||||
# ---- Pakete ----
|
||||
@@ -164,11 +180,10 @@ sudo systemctl enable --now tesm.service
|
||||
sudo systemctl enable --now tesm-check.service tesm-check-restart.timer
|
||||
print_status "Services enabled and started"
|
||||
|
||||
# ---- Migrations-Sicherheitsnetz prüfen ----
|
||||
# Nur relevant, wenn oben tatsächlich eine riskante Migration (abweichende/
|
||||
# unbekannte SCHEMA_VERSION) erkannt wurde -- beim einfachen In-Place-Update
|
||||
# oder einer Frischinstallation ist BACKUP_DIR leer und dieser Block wird
|
||||
# komplett übersprungen.
|
||||
# ---- Sicherheitsnetz prüfen ----
|
||||
# Läuft bei JEDEM Update einer bestehenden Installation (NEEDS_MIGRATION_
|
||||
# GUARD=1, siehe oben) -- nur bei einer Frischinstallation ist BACKUP_DIR
|
||||
# leer und dieser Block wird komplett übersprungen.
|
||||
if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then
|
||||
step "Verifying migrated installation"
|
||||
HEALTHY=0
|
||||
@@ -182,15 +197,23 @@ if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then
|
||||
if [ "$HEALTHY" -eq 1 ]; then
|
||||
print_status "Migration verified healthy"
|
||||
rm -rf "$BACKUP_DIR"
|
||||
echo -e "${GREEN}✔${NC} Migration erfolgreich -- Backup wieder entfernt."
|
||||
echo -e "${GREEN}✔${NC} Update erfolgreich -- Backup wieder entfernt."
|
||||
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 rm -rf /srv/tesm
|
||||
sudo mv "$BACKUP_DIR" /srv/tesm
|
||||
# Fehlgeschlagene Installation NICHT einfach löschen, sondern separat
|
||||
# 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
|
||||
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
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.3
|
||||
1.0.5
|
||||
|
||||
+210
-7
@@ -209,6 +209,7 @@ PERMISSIONS = {
|
||||
"view_key": "logs_group.view",
|
||||
"children": {
|
||||
"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_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)",
|
||||
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog 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.edit": "Benutzer ändern (inkl. Löschen)",
|
||||
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
|
||||
@@ -333,6 +335,7 @@ NAV_ITEMS = [
|
||||
]},
|
||||
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
|
||||
{"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_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
|
||||
if key == "logs_live":
|
||||
return user.can_view_live_log
|
||||
if key == "logs_history":
|
||||
return user.can_view_log_history
|
||||
if key == "logs_activity":
|
||||
return user.can_view_activity_log
|
||||
if key == "logs_kea":
|
||||
@@ -552,6 +557,14 @@ class User(UserMixin):
|
||||
def can_view_live_log(self):
|
||||
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
|
||||
def can_view_activity_log(self):
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war. Sucht
|
||||
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")
|
||||
@login_required
|
||||
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:
|
||||
return "Keine Berechtigung.", 403
|
||||
latest_log = _latest_log_file()
|
||||
if not latest_log:
|
||||
return "Keine Logfiles gefunden."
|
||||
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()
|
||||
except Exception as e:
|
||||
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(latest_log)
|
||||
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")
|
||||
@login_required
|
||||
def logs():
|
||||
@@ -5736,16 +5934,21 @@ def logs():
|
||||
latest_log = _latest_log_file()
|
||||
|
||||
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:
|
||||
with open(latest_log, "r") as f:
|
||||
log_content = f.read()
|
||||
except Exception as e:
|
||||
log_content = _tail_lines(latest_log)
|
||||
total_lines = _count_lines(latest_log)
|
||||
except OSError as e:
|
||||
log_content = f"Fehler beim Lesen des Logs: {e}"
|
||||
total_lines = None
|
||||
|
||||
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.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)
|
||||
========================================================================== */
|
||||
|
||||
@@ -211,6 +211,59 @@
|
||||
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 ---------------- */
|
||||
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
|
||||
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"/>',
|
||||
"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"/>',
|
||||
"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"/>',
|
||||
"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"/>',
|
||||
|
||||
@@ -8,18 +8,31 @@
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<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>
|
||||
<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;" 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 id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
|
||||
</div>
|
||||
@@ -43,7 +56,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const intervalMinutes = {{ global_check_interval | int }};
|
||||
const intervalMilliseconds = intervalMinutes * 60 * 1000;
|
||||
|
||||
function renderLog(text, logName) {
|
||||
function renderLog(text, logName, totalLines) {
|
||||
const box = document.getElementById("log-box");
|
||||
box.innerHTML = "";
|
||||
const lines = text.split("\n");
|
||||
@@ -55,11 +68,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (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() {
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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