diff --git a/srv/tesm/VERSION b/srv/tesm/VERSION index 18efdb9..512a1fa 100644 --- a/srv/tesm/VERSION +++ b/srv/tesm/VERSION @@ -1 +1 @@ -1.1.8 +1.1.9 diff --git a/srv/tesm/app.py b/srv/tesm/app.py index 5c8e09c..47bb846 100644 --- a/srv/tesm/app.py +++ b/srv/tesm/app.py @@ -7706,21 +7706,68 @@ def kea_log(): return render_template("logs_kea.html", log_content=log_content) +AUDIT_LOG_PAGE_SIZE = 300 + + @app.route("/logs/aenderungen") @login_required def activity_log(): """Auditlog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen, - Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts — die stehen im Live-Log.""" + Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts — die stehen im Live-Log. + Lädt anfangs nur die neuesten AUDIT_LOG_PAGE_SIZE Zeilen (Performance bei + einer ggf. noch nicht archivierten, großen Tabelle) -- ältere Zeilen + werden bei Bedarf über "Weitere 300 laden" bzw. "Alle laden" (siehe + activity_log_more()) nachgeladen, ohne die Seite neu zu laden.""" if not current_user.can_view_activity_log: 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" + "SELECT id, ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT ?", + (AUDIT_LOG_PAGE_SIZE,), + ).fetchall() + total_count = conn.execute("SELECT COUNT(*) AS n FROM audit_log").fetchone()["n"] + conn.close() + + oldest_loaded_id = entries[-1]["id"] if entries else None + has_more = len(entries) >= AUDIT_LOG_PAGE_SIZE and total_count > len(entries) + return render_template( + "activity_log.html", entries=entries, total_count=total_count, + oldest_loaded_id=oldest_loaded_id, has_more=has_more, + ) + + +@app.route("/logs/aenderungen/more") +@login_required +def activity_log_more(): + """AJAX-Nachladen älterer Auditlog-Zeilen in AUDIT_LOG_PAGE_SIZE-Schritten + (Cursor-Pagination über die id-Spalte, absteigend) -- liefert reines + HTML-Zeilenfragment (dieselbe Zeilen-Darstellung wie die Erstladung, über + das gemeinsame Makro in _audit_log_macros.html) statt JSON, damit das + Anhängen im Frontend ein einfaches insertAdjacentHTML bleibt.""" + if not current_user.can_view_activity_log: + return "Keine Berechtigung.", 403 + try: + before_id = int(request.args.get("before_id", "")) + except (TypeError, ValueError): + return "Ungültiger Parameter.", 400 + + conn = get_db_connection() + entries = conn.execute( + "SELECT id, ts, username, action, target, details FROM audit_log WHERE id < ? ORDER BY id DESC LIMIT ?", + (before_id, AUDIT_LOG_PAGE_SIZE), ).fetchall() conn.close() - return render_template("activity_log.html", entries=entries) + + oldest_loaded_id = entries[-1]["id"] if entries else before_id + has_more = len(entries) >= AUDIT_LOG_PAGE_SIZE + html = render_template("_audit_log_rows.html", entries=entries) + resp = app.response_class(html, mimetype="text/html") + resp.headers["X-Row-Count"] = str(len(entries)) + resp.headers["X-Has-More"] = "1" if has_more else "0" + resp.headers["X-Oldest-Id"] = str(oldest_loaded_id) + return resp diff --git a/srv/tesm/templates/_audit_log_macros.html b/srv/tesm/templates/_audit_log_macros.html new file mode 100644 index 0000000..25ae453 --- /dev/null +++ b/srv/tesm/templates/_audit_log_macros.html @@ -0,0 +1,67 @@ +{# + Gemeinsame Auditlog-Zeilendarstellung -- von activity_log.html (Erstladung) + UND _audit_log_rows.html (AJAX-Nachladen über "Weitere 500 laden") + importiert, damit beide garantiert dieselbe Darstellung erzeugen und + action_labels/action_icons/category_of nicht an zwei Stellen gepflegt + werden müssen. +#} +{% set action_labels = { + "settings.update": "Einstellung geändert", + "device.create": "Gerät angelegt", + "device.edit": "Gerät bearbeitet", + "device.delete": "Gerät gelöscht", + "device.activate": "Gerät aktiviert", + "device.deactivate": "Gerät deaktiviert", + "switch.create": "Switch angelegt", + "switch.edit": "Switch bearbeitet", + "switch.delete": "Switch gelöscht", + "credential.create": "Zugangsdaten angelegt", + "credential.edit": "Zugangsdaten bearbeitet", + "credential.delete": "Zugangsdaten gelöscht", + "user.create": "Benutzer angelegt", + "user.edit": "Benutzer bearbeitet", + "user.delete": "Benutzer gelöscht", + "user.assign_group": "Gruppe zugewiesen", + "group.create": "Gruppe angelegt", + "group.edit": "Gruppe bearbeitet", + "group.delete": "Gruppe gelöscht", + "group.assign_admins": "Admin-Zuweisung geändert", + "profile.update": "Profil aktualisiert", + "profile.password": "Passwort geändert", + "check.run_now": "Prüfung manuell gestartet", + "fileshare.upload": "Datei(en) hochgeladen", + "fileshare.mkdir": "Ordner angelegt", + "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": '', + "create": '', + "edit": '', + "activate": '', + "deactivate": '', +} %} +{% set create_kinds = ("create", "upload", "mkdir") %} + +{% macro category_of(kind) %}{% if kind in create_kinds %}create{% elif kind == "delete" %}delete{% else %}edit{% endif %}{% endmacro %} + +{% macro audit_row(e) %} +{% set kind = e['action'].split('.')[-1] %} +{% set cat = category_of(kind)|trim %} + + {{ e['ts'] }} + {{ e['username'] }} + + + {{ (action_icons.get(kind) or action_icons['edit'])|safe }} + {{ action_labels.get(e['action'], e['action']) }} + + + {{ e['target'] or '—' }} + {{ e['details'] or '—' }} + +{% endmacro %} diff --git a/srv/tesm/templates/_audit_log_rows.html b/srv/tesm/templates/_audit_log_rows.html new file mode 100644 index 0000000..b3659cc --- /dev/null +++ b/srv/tesm/templates/_audit_log_rows.html @@ -0,0 +1,5 @@ +{# Reines Zeilenfragment fürs AJAX-Nachladen (activity_log_more()) -- kein + umschließendes /, wird per insertAdjacentHTML direkt an das + bestehende tbody von #auditTable angehängt. #} +{% import "_audit_log_macros.html" as m %} +{% for e in entries %}{{ m.audit_row(e) }}{% endfor %} diff --git a/srv/tesm/templates/activity_log.html b/srv/tesm/templates/activity_log.html index 24f2358..5efcad2 100644 --- a/srv/tesm/templates/activity_log.html +++ b/srv/tesm/templates/activity_log.html @@ -1,56 +1,18 @@ {% extends "base.html" %} {% set active_page = "logs" %} {% block page_title %}Auditlog{% endblock %} -{% block page_sub %}
{{ entries|length }} Einträge (letzte 500)
{% endblock %} +{% block page_sub %} +
+ {% if has_more %}{{ entries|length }} von {{ total_count }} Einträgen geladen{% else %}{{ entries|length }} Eintrag{{ 'e' if entries|length != 1 else '' }}{% endif %} +
+{% endblock %} {% block content %} - -{% set action_labels = { - "settings.update": "Einstellung geändert", - "device.create": "Gerät angelegt", - "device.edit": "Gerät bearbeitet", - "device.delete": "Gerät gelöscht", - "device.activate": "Gerät aktiviert", - "device.deactivate": "Gerät deaktiviert", - "switch.create": "Switch angelegt", - "switch.edit": "Switch bearbeitet", - "switch.delete": "Switch gelöscht", - "credential.create": "Zugangsdaten angelegt", - "credential.edit": "Zugangsdaten bearbeitet", - "credential.delete": "Zugangsdaten gelöscht", - "user.create": "Benutzer angelegt", - "user.edit": "Benutzer bearbeitet", - "user.delete": "Benutzer gelöscht", - "user.assign_group": "Gruppe zugewiesen", - "group.create": "Gruppe angelegt", - "group.edit": "Gruppe bearbeitet", - "group.delete": "Gruppe gelöscht", - "group.assign_admins": "Admin-Zuweisung geändert", - "profile.update": "Profil aktualisiert", - "profile.password": "Passwort geändert", - "check.run_now": "Prüfung manuell gestartet", - "fileshare.upload": "Datei(en) hochgeladen", - "fileshare.mkdir": "Ordner angelegt", - "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": '', - "create": '', - "edit": '', - "activate": '', - "deactivate": '', -} %} -{% set create_kinds = ("create", "upload", "mkdir") %} - -{% macro category_of(kind) %}{% if kind in create_kinds %}create{% elif kind == "delete" %}delete{% else %}edit{% endif %}{% endmacro %} +{% import "_audit_log_macros.html" as m %} {% set ns = namespace(create=0, delete=0, edit=0) %} {% for e in entries %} - {% set cat = category_of(e['action'].split('.')[-1])|trim %} + {% set cat = m.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 %} @@ -59,19 +21,19 @@
Alle
-
{{ entries|length }}
+
{{ entries|length }}
Hinzufügen
-
{{ ns.create }}
+
{{ ns.create }}
Änderungen
-
{{ ns.edit }}
+
{{ ns.edit }}
Löschungen
-
{{ ns.delete }}
+
{{ ns.delete }}
@@ -92,29 +54,21 @@
- {% for e in entries %} - {% set kind = e['action'].split('.')[-1] %} - {% set cat = category_of(kind)|trim %} - - - - - - - - {% else %} + {% for e in entries %}{{ m.audit_row(e) }}{% else %} {% endfor %}
Details
{{ e['ts'] }}{{ e['username'] }} - - {{ (action_icons.get(kind) or action_icons['edit'])|safe }} - {{ action_labels.get(e['action'], e['action']) }} - - {{ e['target'] or '—' }}{{ e['details'] or '—' }}
Noch keine Änderungen protokolliert.
+
+ + +
{% endblock %} @@ -124,11 +78,11 @@ document.addEventListener("DOMContentLoaded", function () { const searchInput = document.getElementById("auditSearch"); const noResults = document.getElementById("auditNoResults"); - const rows = Array.from(document.querySelectorAll("#auditTable tbody tr")); let activeCategory = null; function applyFilters() { const q = (searchInput ? searchInput.value : "").trim().toLowerCase(); + const rows = Array.from(document.querySelectorAll("#auditTable tbody tr")); let anyVisible = false; rows.forEach(function (row) { if (row.classList.contains("empty-row")) return; @@ -156,7 +110,106 @@ document.addEventListener("DOMContentLoaded", function () { }); }); + /* ---- Nachladen älterer Einträge (Mehr laden / Alle laden) ---- */ + const totalCount = {{ total_count }}; + const entryCountLabel = document.getElementById("auditEntryCount"); + + function recomputeStats() { + const rows = Array.from(document.querySelectorAll("#auditTable tbody tr[data-category]")); + const counts = { create: 0, edit: 0, delete: 0 }; + rows.forEach(function (r) { counts[r.dataset.category] = (counts[r.dataset.category] || 0) + 1; }); + document.getElementById("statAll").textContent = rows.length; + document.getElementById("statCreate").textContent = counts.create; + document.getElementById("statEdit").textContent = counts.edit; + document.getElementById("statDelete").textContent = counts.delete; + if (entryCountLabel) { + entryCountLabel.textContent = rows.length < totalCount + ? rows.length + " von " + totalCount + " Einträgen geladen" + : rows.length + " Eintrag" + (rows.length === 1 ? "" : "e"); + } + return rows.length; + } + + const loadMoreBtn = document.getElementById("auditLoadMoreBtn"); + const loadAllBtn = document.getElementById("auditLoadAllBtn"); + const loadMoreWrap = document.getElementById("auditLoadMoreWrap"); + const tbody = document.querySelector("#auditTable tbody"); + const MORE_URL = "{{ url_for('activity_log_more') }}"; + + // Laedt genau einen weiteren 300er-Block nach und haengt ihn an -- von + // beiden Buttons genutzt: "Mehr laden" ruft das einmal auf, "Alle laden" + // ruft es wiederholt auf, bis der Server "keine weiteren mehr" meldet. + function loadMoreOnce() { + const oldestId = loadMoreBtn.dataset.oldestId; + if (!oldestId) return Promise.resolve({ hasMore: false }); + return fetch(MORE_URL + "?before_id=" + encodeURIComponent(oldestId)) + .then(function (r) { + const hasMore = r.headers.get("X-Has-More") === "1"; + const newOldestId = r.headers.get("X-Oldest-Id"); + return r.text().then(function (html) { + return { html: html, hasMore: hasMore, newOldestId: newOldestId }; + }); + }) + .then(function (result) { + const emptyRow = tbody.querySelector(".empty-row"); + if (emptyRow) emptyRow.remove(); + tbody.insertAdjacentHTML("beforeend", result.html); + if (result.newOldestId) loadMoreBtn.dataset.oldestId = result.newOldestId; + return result; + }); + } + + if (loadMoreBtn) { + loadMoreBtn.addEventListener("click", function () { + loadMoreBtn.disabled = true; + if (loadAllBtn) loadAllBtn.disabled = true; + const originalText = loadMoreBtn.textContent; + loadMoreBtn.textContent = "Lädt …"; + loadMoreOnce() + .then(function (result) { + if (!result.hasMore) loadMoreWrap.classList.add("hidden"); + recomputeStats(); + applyFilters(); + }) + .catch(function () { /* Button unten wird trotzdem wieder aktiviert */ }) + .then(function () { + loadMoreBtn.disabled = false; + if (loadAllBtn) loadAllBtn.disabled = false; + loadMoreBtn.textContent = originalText; + }); + }); + } + + if (loadAllBtn) { + loadAllBtn.addEventListener("click", function () { + loadMoreBtn.disabled = true; + loadAllBtn.disabled = true; + const originalText = loadAllBtn.textContent; + + function step() { + loadAllBtn.textContent = "Lädt … (" + recomputeStats() + " von " + totalCount + ")"; + return loadMoreOnce().then(function (result) { + return result.hasMore ? step() : null; + }); + } + + step() + .then(function () { + loadMoreWrap.classList.add("hidden"); + }) + .catch(function () { /* teilweise geladene Eintraege bleiben stehen */ }) + .then(function () { + recomputeStats(); + applyFilters(); + loadMoreBtn.disabled = false; + loadAllBtn.disabled = false; + loadAllBtn.textContent = originalText; + }); + }); + } + applyFilters(); + recomputeStats(); }); {% endblock %}