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 @@
Details
- {% for e in entries %}
- {% 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 '—' }}
-
- {% else %}
+ {% for e in entries %}{{ m.audit_row(e) }}{% else %}
Noch keine Änderungen protokolliert.
{% endfor %}
Keine Einträge für diese Auswahl.
+
+
+ Mehr laden (300)
+
+
+ Alle laden
+
+
{% 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 %}