From 2eaefc9e1fa9ccad992a61dda26d91dbd501f522 Mon Sep 17 00:00:00 2001 From: alientim Date: Sat, 22 Aug 2026 21:06:33 +0200 Subject: [PATCH] Auditlog-Archivierung, Verlauf-RAW mit Zeilennummern, UI-Politur (v1.1.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auditlog waechst nur bis zu einem Schwellenwert (20.000 Zeilen): danach werden die aeltesten VOLLSTAENDIGEN Kalendertage (der laufende Tag wird nie mitten am Tag angefasst) tageweise in je eine Datei audit-YYYY-MM-DD.log unter /var/log/tesm/audit-archive archiviert und aus der DB geloescht, bis wieder 15.000 Zeilen unterschritten sind. Laeuft als taeglich pruefender Hintergrund-Thread (gate: TESM_WEB_PROCESS, wie beim bestehenden Fileshare-Sweep), zusaetzlich einmalig sofort beim Dienststart. - Archivierte Auditlog-Tage sind unter Verlauf einsehbar (RAW-Ansicht) und koennen dort als ein ZIP exportiert werden, was sie danach vom Server loescht -- bewusst nur manuell ausgeloest statt automatisch, mit eigenem Recht logs_history.edit (neue "E"-Spalte bei Verlauf in der Gruppen-Rechtematrix, ergibt sich automatisch aus GROUP_ROW_TYPES). Aufbewahrung der Archivdateien selbst ist unbegrenzt; bei knappem Speicherplatz (< 1 GiB frei im Log-Verzeichnis) erscheint ein Warnhinweis auf der Verlauf-Seite. - Verlauf -> "Komplettes Log (RAW)": Inhalt jetzt mit Zeilennummern (reine CSS-Counter-Loesung, kein Extra-Markup pro Zeile) -- Live-Log-RAW und nginx-RAW-Konfiguration bleiben bewusst unveraendert (nur an dieser Stelle angefragt). - Auditlog-Tabelle: Avatar-Spalte entfernt, Spaltenbreiten (Zeitpunkt/ Benutzer/Aktion/Ziel) schrumpfen jetzt auf den Inhalt statt zu strecken, "Ziel" nimmt nur noch so viel Platz wie noetig; Aktion-Pills brechen generell nicht mehr um (white-space: nowrap). - "Änderungen" endgueltig zu "Auditlog" umbenannt (Recht-Label, Kommentare); die "Änderungen"-Filterkachel selbst (Bearbeiten-Kategorie) behaelt bewusst ihren Namen. Co-Authored-By: Claude Sonnet 5 --- srv/tesm/VERSION | 2 +- srv/tesm/app.py | 278 +++++++++++++++++++++++++-- srv/tesm/static/css/style.css | 38 ++++ srv/tesm/static/js/app.js | 25 ++- srv/tesm/templates/activity_log.html | 116 +++++++++-- srv/tesm/templates/logs_history.html | 52 ++++- 6 files changed, 472 insertions(+), 39 deletions(-) diff --git a/srv/tesm/VERSION b/srv/tesm/VERSION index e25d8d9..0664a8f 100644 --- a/srv/tesm/VERSION +++ b/srv/tesm/VERSION @@ -1 +1 @@ -1.1.5 +1.1.6 diff --git a/srv/tesm/app.py b/srv/tesm/app.py index 4b11872..005d3b5 100644 --- a/srv/tesm/app.py +++ b/srv/tesm/app.py @@ -94,6 +94,18 @@ TESM_CHANGES_LOG_PATH = os.path.join(TESM_LOG_DIR, "changes.log") TESM_APP_LOG_PATH = os.path.join(TESM_LOG_DIR, "app.log") TESM_KEA_LOG_PATH = os.environ.get("TESM_KEA_LOG_PATH", "/var/log/kea/kea-dhcp4.log") LOGROTATE_CONFIG_PATH = os.environ.get("TESM_LOGROTATE_CONFIG", "/etc/logrotate.d/tesm") + +# Auditlog-Archivierung: eigenständig und bewusst UNABHÄNGIG von der +# logrotate-Rotation von changes.log (siehe _archive_old_audit_log_rows). +# Schwellenwerte mit dem Nutzer abgestimmt (20.000 Zeilen Trigger, Ziel +# 15.000) -- Aufbewahrung der Archivdateien selbst ist bewusst unbegrenzt, +# stattdessen nur eine Warnung bei knappem Speicherplatz plus manueller +# Export-und-Löschen-Funktion unter Verlauf. +AUDIT_ARCHIVE_DIR = os.path.join(TESM_LOG_DIR, "audit-archive") +AUDIT_LOG_ARCHIVE_THRESHOLD = 20000 +AUDIT_LOG_ARCHIVE_TARGET = 15000 +AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS = 24 * 60 * 60 +LOG_DISK_SPACE_WARNING_BYTES = 1 * 1024 * 1024 * 1024 # 1 GiB frei try: os.makedirs(TESM_LOG_DIR, exist_ok=True) # War früher 0o777, als noch nicht jeder schreibende Prozess (tesm.service, @@ -281,8 +293,8 @@ 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_history": {"label": "Verlauf", "rows": {"view": "logs_history.view", "edit": "logs_history.edit"}}, + "logs_activity": {"label": "Auditlog", "rows": {"view": "logs_activity.view"}}, "logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}}, }, }, @@ -367,9 +379,10 @@ PERMISSION_LABELS = { "maintenance.edit": "Wartungsaktionen ausführen (SSH-Update, SSH-Neustart)", "papierkorb.view": "Papierkorb ansehen (alle gelöschten Geräte/Switche/Zugangsdaten/Benutzer/Gruppen, unabhängig von deren eigenen Leserechten)", "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": "Auditlog lesen", "logs_kea.view": "Kea-DHCP-Log lesen", - "logs_history.view": "Log-Verlauf lesen (ältere, durch logrotate rotierte Kopien des Live-Logs)", + "logs_history.view": "Log-Verlauf lesen (ältere, durch logrotate rotierte Kopien des Live-Logs, sowie archivierte Auditlog-Tage)", + "logs_history.edit": "Archivierte Auditlog-Tage exportieren (ZIP-Download) und danach vom Server löschen", "users.view": "Benutzer lesen", "users.create": "Benutzer anlegen", "users.edit": "Benutzer ändern (inkl. Löschen)", "groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen", @@ -434,7 +447,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_activity", "label": "Auditlog", "icon": "history", "endpoint": "activity_log"}, {"key": "logs_kea", "label": "Kea-DHCP", "icon": "network", "endpoint": "kea_log"}, ]}, ] @@ -667,6 +680,14 @@ class User(UserMixin): automatisch auch durch ältere Logstände blättern zu können.""" return self.has_permission("logs_history.view") + @property + def can_manage_log_history(self): + """Zusätzlich zum reinen Lesen (can_view_log_history): archivierte + Auditlog-Tage als ZIP exportieren und danach vom Server löschen -- + bewusst eigenes Edit-Recht, weil das eine destruktive Aktion ist, + die über reines Einsehen des Verlaufs hinausgeht.""" + return self.has_permission("logs_history.edit") + @property def can_view_activity_log(self): return self.has_permission("logs_activity.view") @@ -1186,7 +1207,7 @@ def _append_changes_log(ts, who, action, target, details): """Spiegelt jeden Änderungslog-Eintrag zusätzlich in eine eigene, per logrotate rotierte Datei (TESM_CHANGES_LOG_PATH) — die audit_log-Tabelle bleibt die Quelle für die durchsuchbare/sortierbare Seite unter - Logs → Änderungen, wächst aber unbegrenzt weiter; die Datei bekommt + Logs → Auditlog, wächst aber unbegrenzt weiter; die Datei bekommt dieselbe wöchentliche Rotation/Aufbewahrung wie die anderen drei Logs. Ein Schreibfehler hier darf den eigentlichen, DB-basierten Audit-Trail nicht gefährden — daher bewusst best-effort mit breitem except.""" @@ -1233,6 +1254,151 @@ def log_action_system(action, target=None, details=None): _append_changes_log(ts, "system", action, target, details) +def _format_audit_line(ts, who, action, target, details): + """Gleiches Zeilenformat wie _append_changes_log(), damit ein + archivierter Auditlog-Tag optisch identisch zu changes.log aussieht.""" + line = f"{ts} [{who}] {action}" + if target: + line += f" — {target}" + if details: + line += f": {details}" + return line + + +def _audit_archive_file_path(day_str): + """day_str: 'YYYY-MM-DD', ausschließlich aus intern erzeugten + DB-Zeitstempeln abgeleitet, nie aus Nutzereingabe -- anders als bei + _resolve_log_history_file() ist hier kein Path-Traversal-Schutz nötig.""" + return os.path.join(AUDIT_ARCHIVE_DIR, f"audit-{day_str}.log") + + +def _audit_archive_files(): + """Für die Verlauf-Seite: alle bereits archivierten Auditlog-Tage, + neueste zuerst, mit Größe für die Dateiliste.""" + if not os.path.isdir(AUDIT_ARCHIVE_DIR): + return [] + out = [] + for name in os.listdir(AUDIT_ARCHIVE_DIR): + m = re.fullmatch(r"audit-(\d{4}-\d{2}-\d{2})\.log", name) + if not m: + continue + full_path = os.path.join(AUDIT_ARCHIVE_DIR, name) + try: + size = os.path.getsize(full_path) + except OSError: + continue + out.append({"filename": name, "day": m.group(1), "size": size}) + out.sort(key=lambda f: f["day"], reverse=True) + return out + + +def _resolve_audit_archive_file(filename): + """Path-Traversal-Schutz analog _resolve_log_history_file(): nur exakt + ein Dateiname im erwarteten audit-YYYY-MM-DD.log-Muster, innerhalb von + AUDIT_ARCHIVE_DIR.""" + if not filename or not re.fullmatch(r"audit-\d{4}-\d{2}-\d{2}\.log", filename): + return None + full_path = os.path.join(AUDIT_ARCHIVE_DIR, filename) + if os.path.dirname(os.path.abspath(full_path)) != os.path.abspath(AUDIT_ARCHIVE_DIR): + return None + if not os.path.isfile(full_path): + return None + return full_path + + +def _log_disk_space_warning(): + """None wenn genug Platz frei ist, sonst ein Warnhinweis-Text fürs + Verlauf-Template. Bewusst nur eine Warnung (kein automatisches + Löschen) -- Aufräumen bleibt eine bewusste, manuelle Entscheidung + über den Export-und-Löschen-Button.""" + try: + free = shutil.disk_usage(TESM_LOG_DIR).free + except OSError: + return None + if free >= LOG_DISK_SPACE_WARNING_BYTES: + return None + free_mb = free // (1024 * 1024) + return ( + f"Wenig Speicherplatz im Log-Verzeichnis (nur noch {free_mb} MB frei). " + f"Archivierte Auditlog-Tage können unten exportiert (ZIP-Download) und " + f"dabei vom Server gelöscht werden, um Platz zu schaffen." + ) + + +def _archive_old_audit_log_rows(): + """Hält die audit_log-Tabelle auf eine vernünftige historische + Zeilenanzahl begrenzt: sobald AUDIT_LOG_ARCHIVE_THRESHOLD überschritten + ist, werden die ältesten VOLLSTÄNDIGEN Kalendertage (alles vor dem + heutigen Tag -- der laufende Tag wird nie mitten am Tag angefasst) in je + eine eigene Datei audit-YYYY-MM-DD.log unter AUDIT_ARCHIVE_DIR + geschrieben und anschließend aus der DB gelöscht, so lange bis + AUDIT_LOG_ARCHIVE_TARGET wieder unterschritten ist oder kein + archivierbarer (nicht-heutiger) Tag mehr übrig ist. + + Bewusst unabhängig von der wöchentlichen logrotate-Rotation von + changes.log (siehe _write_logrotate_config): die Dateien hier sind + tagesgenau benannt, damit sie unter Verlauf direkt nach Kalendertag + auswählbar sind -- eine logrotate-Kopie (changes.log.1.gz usw.) lässt + sich nicht so eindeutig einem Tag zuordnen. + + Schreiben je Tagesdatei erfolgt im Overwrite-Modus ('w', nicht 'a'): + die DB ist bis zum COMMIT des DELETE die alleinige Quelle der Wahrheit + für einen noch nicht archivierten Tag -- ein erneuter Lauf (z.B. nach + einem Absturz zwischen Schreiben und Löschen) erzeugt exakt denselben + Dateiinhalt erneut, statt Zeilen zu duplizieren.""" + conn = get_db_connection() + archived_days = 0 + archived_rows = 0 + try: + total = conn.execute("SELECT COUNT(*) AS n FROM audit_log").fetchone()["n"] + if total <= AUDIT_LOG_ARCHIVE_THRESHOLD: + return + + today_start = datetime.now().strftime("%Y-%m-%d 00:00:00") + os.makedirs(AUDIT_ARCHIVE_DIR, exist_ok=True) + + while total > AUDIT_LOG_ARCHIVE_TARGET: + oldest = conn.execute( + "SELECT MIN(ts) AS ts FROM audit_log WHERE ts < ?", (today_start,) + ).fetchone()["ts"] + if not oldest: + break # nur noch der heutige Tag übrig -- nicht anfassen + day_str = oldest[:10] + day_start, day_end = f"{day_str} 00:00:00", f"{day_str} 23:59:59" + rows = conn.execute( + "SELECT ts, username, action, target, details FROM audit_log " + "WHERE ts >= ? AND ts <= ? ORDER BY ts, id", + (day_start, day_end), + ).fetchall() + if not rows: + break + + lines = [ + _format_audit_line(r["ts"], r["username"], r["action"], r["target"], r["details"]) + for r in rows + ] + with open(_audit_archive_file_path(day_str), "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + conn.execute("DELETE FROM audit_log WHERE ts >= ? AND ts <= ?", (day_start, day_end)) + conn.commit() + + archived_days += 1 + archived_rows += len(rows) + total -= len(rows) + finally: + conn.close() + + if archived_rows: + log_action_system( + "auditlog.archive", + details=( + f"{archived_rows} Einträge über {archived_days} Tag(e) archiviert " + f"(Schwellenwert {AUDIT_LOG_ARCHIVE_THRESHOLD}, Ziel {AUDIT_LOG_ARCHIVE_TARGET})." + ), + ) + + def touch_record(conn, table, key_col, key_val): """Trägt 'zuletzt geändert von/am' direkt am Datensatz ein (Devices/Switches).""" who = current_user.username if current_user.is_authenticated else "system" @@ -1285,7 +1451,7 @@ LOG_ROTATION_DEFAULT_KEEP = 4 def _write_logrotate_config(): """Schreibt/aktualisiert die logrotate-Konfiguration für alle vier - App-Logs (Live, Änderungen, App unter TESM_LOG_DIR; Kea-DHCP separat + App-Logs (Live, Auditlog, App unter TESM_LOG_DIR; Kea-DHCP separat unter TESM_KEA_LOG_PATH — Keas AppArmor-Profil erlaubt nur genau diesen einen Pfad, siehe Kommentar bei TESM_KEA_LOG_PATH) anhand der aktuellen Einstellungen (Systemeinstellungen → Logs). Läuft sowohl beim App-Start @@ -2106,6 +2272,25 @@ if _IS_WEB_PROCESS: threading.Thread(target=_fileshare_sweep_loop, daemon=True).start() +def _audit_archive_loop(): + """Prüft einmal täglich (siehe AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS), ob + die audit_log-Tabelle den Schwellenwert überschritten hat, und + archiviert dann bei Bedarf über _archive_old_audit_log_rows(). Läuft + direkt beim Start einmal sofort (statt erst nach 24h zu prüfen), damit + ein frisch aktualisierter/neu gestarteter Dienst eine bereits + übervolle Tabelle nicht einen ganzen Tag lang unangetastet lässt.""" + while True: + try: + _archive_old_audit_log_rows() + except Exception: + app.logger.error("Auditlog-Archivierung fehlgeschlagen:\n%s", traceback.format_exc()) + time.sleep(AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS) + + +if _IS_WEB_PROCESS: + threading.Thread(target=_audit_archive_loop, daemon=True).start() + + def _current_fileshare_mounts(): """Für die Fileshare-Seite und die Nav-Sichtbarkeit: gemountete Freigaben der AKTUELLEN Session, oder eine leere Liste (lokale Nutzer, @@ -7366,6 +7551,8 @@ def logs_history(): return render_template( "logs_history.html", files=files, selected_name=selected_name, selected_file=selected_file, log_content=log_content, total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES, + audit_files=_audit_archive_files(), disk_warning=_log_disk_space_warning(), + audit_threshold=AUDIT_LOG_ARCHIVE_THRESHOLD, audit_target=AUDIT_LOG_ARCHIVE_TARGET, ) @@ -7387,6 +7574,71 @@ def logs_history_raw(): return response +@app.route("/logs/history/audit-raw") +@login_required +def logs_history_audit_raw(): + """Wie logs_history_raw(), aber für einen einzelnen bereits + archivierten Auditlog-Tag (audit-YYYY-MM-DD.log) statt für eine + Live-Log-Rotation.""" + if not current_user.can_view_log_history: + return "Keine Berechtigung.", 403 + path = _resolve_audit_archive_file(request.args.get("file", "")) + if not path: + return "Unbekannte oder ungültige Archivdatei.", 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 der Datei: {e}" + response = app.response_class(content, mimetype="text/plain") + response.headers["X-Log-Name"] = os.path.basename(path) + return response + + +@app.route("/logs/history/audit-export", methods=["POST"]) +@login_required +def logs_history_audit_export(): + """Exportiert ALLE aktuell archivierten Auditlog-Tage als ein ZIP und + löscht sie danach vom Server -- bewusst eine einzige, manuell + ausgelöste Aktion (Recht logs_history.edit, siehe + User.can_manage_log_history) statt einer automatischen Löschung nach + Ablaufzeit: die Archivdateien selbst bleiben unbegrenzt liegen, bis ein + Admin hier gezielt aufräumt (z.B. wenn _log_disk_space_warning() + anschlägt).""" + if not current_user.can_manage_log_history: + return "Keine Berechtigung.", 403 + + files = _audit_archive_files() + if not files: + flash("Keine archivierten Auditlog-Tage zum Exportieren vorhanden.", "info") + return redirect(url_for("logs_history")) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for f in files: + zf.write(os.path.join(AUDIT_ARCHIVE_DIR, f["filename"]), arcname=f["filename"]) + buf.seek(0) + + deleted = [] + for f in files: + try: + os.remove(os.path.join(AUDIT_ARCHIVE_DIR, f["filename"])) + deleted.append(f["filename"]) + except OSError: + pass + + log_action( + "auditlog.export_delete", + details=f"{len(deleted)} archivierte Auditlog-Datei(en) exportiert und vom Server gelöscht.", + ) + + today_str = datetime.now().strftime("%Y-%m-%d") + return send_file( + buf, mimetype="application/zip", as_attachment=True, + download_name=f"auditlog-archiv-{today_str}.zip", + ) + + @app.route("/logs") @login_required def logs(): @@ -7435,7 +7687,7 @@ def get_kea_log(): @login_required def kea_log(): """Eigene Log-Seite für den Kea-DHCP-Dienst, konsistent mit Live-Log/ - Änderungen — die Konfiguration (siehe _render_kea_config) leitet Keas + Auditlog — die Konfiguration (siehe _render_kea_config) leitet Keas eigene Logging-Ausgabe explizit in TESM_KEA_LOG_PATH statt in den distributionsabhängigen Default (meist Syslog), damit sie sich hier genauso wie die anderen Logs einsehen und per logrotate rotieren @@ -7457,22 +7709,18 @@ def kea_log(): @app.route("/logs/aenderungen") @login_required def activity_log(): - """Änderungslog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen, + """Auditlog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen, Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts — die stehen im Live-Log.""" if not current_user.can_view_activity_log: - flash("Keine Berechtigung, das Änderungslog einzusehen.", "danger") + flash("Keine Berechtigung, das Auditlog einzusehen.", "danger") return redirect(url_for("index")) conn = get_db_connection() entries = conn.execute( "SELECT id, ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT 500" ).fetchall() - avatars = { - row["username"]: row["avatar_filename"] - for row in conn.execute("SELECT username, avatar_filename FROM users").fetchall() - } conn.close() - return render_template("activity_log.html", entries=entries, avatars=avatars) + return render_template("activity_log.html", entries=entries) diff --git a/srv/tesm/static/css/style.css b/srv/tesm/static/css/style.css index 8062126..3a2cbe3 100644 --- a/srv/tesm/static/css/style.css +++ b/srv/tesm/static/css/style.css @@ -446,6 +446,18 @@ button { font-family: inherit; } .card-pad { padding: 20px 22px; } +.notice-banner { + padding: 12px 16px; + border-radius: var(--radius); + font-size: 12.5px; + line-height: 1.5; +} +.notice-banner--warning { + background: var(--warning-dim); + color: var(--warning); + border: 1px solid var(--warning); +} + .section-head { display: flex; align-items: flex-start; @@ -498,6 +510,7 @@ button { font-family: inherit; } border-radius: 999px; font-size: 12px; font-weight: 650; + white-space: nowrap; } .pill svg { width: 12px; height: 12px; } .pill::before { @@ -1151,6 +1164,31 @@ select { } [data-theme="light"] .raw-log-content { background: #0e1116; } +/* Zeilennummern im RAW-Log-Popup (aktuell nur "Verlauf") -- Nummer kommt + rein aus CSS-Countern (kein Extra-Markup pro Zeile mit fest eingebrannter + Zahl), damit Filtern/Kopieren des reinen Logtexts unverändert bleibt. */ +.raw-log-content.with-line-numbers { + counter-reset: raw-line; + white-space: normal; +} +.raw-log-content.with-line-numbers .raw-log-line { + display: flex; + white-space: pre-wrap; + word-break: break-word; +} +.raw-log-content.with-line-numbers .raw-log-line::before { + counter-increment: raw-line; + content: counter(raw-line); + flex: 0 0 auto; + min-width: 3.5em; + margin-right: 14px; + padding-right: 10px; + border-right: 1px solid var(--border); + text-align: right; + color: var(--text-faint); + user-select: none; +} + /* ========================================================================== Permission checklists (Gruppen) ========================================================================== */ diff --git a/srv/tesm/static/js/app.js b/srv/tesm/static/js/app.js index 22ff47e..1555e80 100644 --- a/srv/tesm/static/js/app.js +++ b/srv/tesm/static/js/app.js @@ -245,12 +245,30 @@ }); } - window.openRawLogModal = function (url, title) { + function renderRawLogLines(container, text) { + // Eine Zeile = ein
, Nummer kommt per CSS-Counter (::before) -- + // so bleibt der eigentliche Zeileninhalt reiner textContent (kein XSS- + // Risiko) und die Nummerierung muss nirgends von Hand mitgezählt werden. + const lines = text.split("\n"); + if (lines.length && lines[lines.length - 1] === "") lines.pop(); // trailing \n erzeugt sonst eine Phantomzeile + container.textContent = ""; + const frag = document.createDocumentFragment(); + lines.forEach((line) => { + const row = document.createElement("div"); + row.className = "raw-log-line"; + row.textContent = line; + frag.appendChild(row); + }); + container.appendChild(frag); + } + + window.openRawLogModal = function (url, title, withLineNumbers) { 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.classList.toggle("with-line-numbers", !!withLineNumbers); content.textContent = "Lade …"; meta.textContent = ""; openModal("raw-log-modal"); @@ -260,7 +278,10 @@ if (logName) meta.textContent = logName; return r.text(); }) - .then((text) => { content.textContent = text; }) + .then((text) => { + if (withLineNumbers) renderRawLogLines(content, text); + else content.textContent = text; + }) .catch(() => { content.textContent = "Fehler beim Laden des Logs."; }); }; diff --git a/srv/tesm/templates/activity_log.html b/srv/tesm/templates/activity_log.html index 020e031..24f2358 100644 --- a/srv/tesm/templates/activity_log.html +++ b/srv/tesm/templates/activity_log.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% set active_page = "logs" %} -{% block page_title %}Änderungen{% endblock %} +{% block page_title %}Auditlog{% endblock %} {% block page_sub %}
{{ entries|length }} Einträge (letzte 500)
{% endblock %} {% block content %} @@ -34,6 +34,8 @@ "fileshare.delete": "Datei/Ordner gelöscht", "fileshare.rename": "Datei/Ordner umbenannt", "fileshare.download": "Als ZIP heruntergeladen", + "auditlog.archive": "Auditlog archiviert", + "auditlog.export_delete": "Auditlog-Archiv exportiert", } %} {% set action_icons = { "delete": '', @@ -42,32 +44,63 @@ "activate": '', "deactivate": '', } %} -{% set action_kind_class = { - "create": "action-pill--create", - "upload": "action-pill--create", - "mkdir": "action-pill--create", - "delete": "action-pill--delete", -} %} +{% set create_kinds = ("create", "upload", "mkdir") %} + +{% macro category_of(kind) %}{% if kind in create_kinds %}create{% elif kind == "delete" %}delete{% else %}edit{% endif %}{% endmacro %} + +{% set ns = namespace(create=0, delete=0, edit=0) %} +{% for e in entries %} + {% set cat = category_of(e['action'].split('.')[-1])|trim %} + {% if cat == "create" %}{% set ns.create = ns.create + 1 %} + {% elif cat == "delete" %}{% set ns.delete = ns.delete + 1 %} + {% else %}{% set ns.edit = ns.edit + 1 %}{% endif %} +{% endfor %} + +
+
+
Alle
+
{{ entries|length }}
+
+
+
Hinzufügen
+
{{ ns.create }}
+
+
+
Änderungen
+
{{ ns.edit }}
+
+
+
Löschungen
+
{{ ns.delete }}
+
+
+
+
+ + +
+
- - +
ZeitpunktBenutzerAktionZielDetails
+ + + + + + + {% for e in entries %} {% set kind = e['action'].split('.')[-1] %} - - + {% set cat = category_of(kind)|trim %} + {% else %} - + {% endfor %}
ZeitpunktBenutzerAktionZielDetails
- {% if avatars.get(e['username']) %} - - {% else %} - {{ e['username'][:1]|upper }} - {% endif %} -
{{ e['ts'] }} {{ e['username'] }} - + {{ (action_icons.get(kind) or action_icons['edit'])|safe }} {{ action_labels.get(e['action'], e['action']) }} @@ -76,11 +109,54 @@ {{ e['details'] or '—' }}
Noch keine Änderungen protokolliert.
Noch keine Änderungen protokolliert.
-
+ +
{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/srv/tesm/templates/logs_history.html b/srv/tesm/templates/logs_history.html index 9f2c2a5..e37da02 100644 --- a/srv/tesm/templates/logs_history.html +++ b/srv/tesm/templates/logs_history.html @@ -9,6 +9,7 @@

Log-Verlauf

Aktuelles Live-Log sowie ältere, von logrotate rotierte Kopien davon — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar, auswählbar nach Zeitraum. Aus Performance-Gründen unformatiert (RAW) dargestellt, ohne farbliche Aufbereitung.
+
Zusätzlich weiter unten: bereits archivierte Auditlog-Tage (das Auditlog wird ab {{ "{:,}".format(audit_threshold).replace(",", ".") }} Einträgen automatisch tageweise archiviert, bis {{ "{:,}".format(audit_target).replace(",", ".") }} Einträge unterschritten sind).
@@ -16,7 +17,7 @@ Zurück zur Live-Ansicht
{% endif %} + +
+
+

Archivierte Auditlog-Tage

+
Ältere Auditlog-Einträge, tageweise als eigene Datei ausgelagert, sobald die laufende Tabelle den Schwellenwert überschreitet — die Dateien selbst bleiben unbegrenzt erhalten, bis sie hier bewusst exportiert werden.
+
+ {% if current_user.can_manage_log_history and audit_files %} +
+ +
+ {% endif %} +
+ +{% if disk_warning %} +
{{ disk_warning }}
+{% endif %} + +{% if not audit_files %} +

Noch keine archivierten Auditlog-Tage vorhanden.

+{% else %} +
+ + + + + + + + {% for f in audit_files %} + + + + + + {% endfor %} + +
TagGröße
{{ f.day }}{{ (f.size / 1024)|round(1) }} KB + +
+
+{% endif %} {% endblock %} {% block scripts %}