diff --git a/srv/tesm/VERSION b/srv/tesm/VERSION index ee90284..90a27f9 100644 --- a/srv/tesm/VERSION +++ b/srv/tesm/VERSION @@ -1 +1 @@ -1.0.4 +1.0.5 diff --git a/srv/tesm/app.py b/srv/tesm/app.py index ddab6f9..af6a4a1 100644 --- a/srv/tesm/app.py +++ b/srv/tesm/app.py @@ -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, ) diff --git a/srv/tesm/static/css/style.css b/srv/tesm/static/css/style.css index 0f07a04..be39871 100644 --- a/srv/tesm/static/css/style.css +++ b/srv/tesm/static/css/style.css @@ -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) ========================================================================== */ diff --git a/srv/tesm/static/js/app.js b/srv/tesm/static/js/app.js index c6a4523..22ff47e 100644 --- a/srv/tesm/static/js/app.js +++ b/srv/tesm/static/js/app.js @@ -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 = ` +
`; + 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/ diff --git a/srv/tesm/templates/base.html b/srv/tesm/templates/base.html index 98ad43f..5c36318 100644 --- a/srv/tesm/templates/base.html +++ b/srv/tesm/templates/base.html @@ -19,6 +19,7 @@ "key": '+ Noch keine rotierten Live-Logs vorhanden — sobald logrotate das aktuelle + Live-Log + zum ersten Mal rotiert (siehe Rotations-Intervall unter Systemeinstellungen), erscheinen ältere Stände hier. +
+{% else %} + +