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 }}
+
+
+
-
- Zeitpunkt Benutzer Aktion Ziel Details
+
+
+ Zeitpunkt
+ Benutzer
+ Aktion
+ Ziel
+ Details
+
{% for e in entries %}
{% set kind = e['action'].split('.')[-1] %}
-
-
- {% if avatars.get(e['username']) %}
-
- {% else %}
- {{ e['username'][:1]|upper }}
- {% endif %}
-
+ {% 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']) }}
@@ -76,11 +109,54 @@
{{ e['details'] or '—' }}
{% else %}
- Noch keine Änderungen protokolliert.
+ Noch keine Änderungen protokolliert.
{% endfor %}
-
+ Keine Einträge für diese Auswahl.
+
{% 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).
{% 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 %}
+
+
+
+ Tag
+ Größe
+
+
+
+ {% for f in audit_files %}
+
+ {{ f.day }}
+ {{ (f.size / 1024)|round(1) }} KB
+
+
+ Ansehen (RAW)
+
+
+
+ {% endfor %}
+
+
+
+{% endif %}
{% endblock %}
{% block scripts %}