Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc4b165d23 | ||
|
|
e81735d5ec | ||
|
|
2eaefc9e1f | ||
|
|
cb8b929c3c |
+1
-1
@@ -1 +1 @@
|
||||
1.1.4
|
||||
1.1.8
|
||||
|
||||
+263
-15
@@ -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,150 @@ 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",
|
||||
"Auditlog",
|
||||
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 +1450,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 +2271,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 +7550,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 +7573,72 @@ 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",
|
||||
"Auditlog",
|
||||
f"{len(deleted)} Datei(en) exportiert und gelöscht: {', '.join(deleted)}",
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -516,6 +529,11 @@ button { font-family: inherit; }
|
||||
.pill.action-pill { background: var(--muted-dim); color: var(--text-dim); }
|
||||
.pill.action-pill::before { display: none; }
|
||||
.pill.action-pill svg { width: 13px; height: 13px; }
|
||||
/* Auditlog: Hinzufuegen gruen, Loeschen rot, alles andere (Bearbeiten/
|
||||
Aktivieren/Zuweisen/...) orange -- siehe activity_log.html */
|
||||
.pill.action-pill--create { background: var(--success-dim); color: var(--success); }
|
||||
.pill.action-pill--delete { background: var(--danger-dim); color: var(--danger); }
|
||||
.pill.action-pill--edit { background: var(--accent-dim); color: var(--accent-strong); }
|
||||
|
||||
.avatar-sm {
|
||||
display: inline-flex;
|
||||
@@ -1146,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)
|
||||
========================================================================== */
|
||||
|
||||
@@ -245,12 +245,30 @@
|
||||
});
|
||||
}
|
||||
|
||||
window.openRawLogModal = function (url, title) {
|
||||
function renderRawLogLines(container, text) {
|
||||
// Eine Zeile = ein <div>, 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."; });
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "logs" %}
|
||||
{% block page_title %}Änderungen{% endblock %}
|
||||
{% block page_title %}Auditlog{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -29,6 +29,13 @@
|
||||
"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": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
|
||||
@@ -37,26 +44,63 @@
|
||||
"activate": '<path d="M20 6L9 17l-5-5"/>',
|
||||
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
|
||||
} %}
|
||||
{% 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 %}
|
||||
|
||||
<div class="stat-row" style="margin-bottom:18px;">
|
||||
<div class="stat-card" data-category-filter="">
|
||||
<div class="stat-label">Alle</div>
|
||||
<div class="stat-value">{{ entries|length }}</div>
|
||||
</div>
|
||||
<div class="stat-card" data-category-filter="create">
|
||||
<div class="stat-label">Hinzufügen</div>
|
||||
<div class="stat-value" style="color:var(--success);">{{ ns.create }}</div>
|
||||
</div>
|
||||
<div class="stat-card" data-category-filter="edit">
|
||||
<div class="stat-label">Änderungen</div>
|
||||
<div class="stat-value" style="color:var(--accent-strong);">{{ ns.edit }}</div>
|
||||
</div>
|
||||
<div class="stat-card" data-category-filter="delete">
|
||||
<div class="stat-label">Löschungen</div>
|
||||
<div class="stat-value" style="color:var(--danger);">{{ ns.delete }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||
<input type="text" id="auditSearch" placeholder="Benutzer, Aktion, Ziel oder Details durchsuchen...">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th style="width:1%;"></th><th>Zeitpunkt</th><th>Benutzer</th><th>Aktion</th><th>Ziel</th><th>Details</th></tr></thead>
|
||||
<table class="data-table" id="auditTable" data-sortable>
|
||||
<thead><tr>
|
||||
<th data-sort-key="ts" style="width:1%; white-space:nowrap;">Zeitpunkt</th>
|
||||
<th data-sort-key="user" style="width:1%; white-space:nowrap;">Benutzer</th>
|
||||
<th data-sort-key="action" style="width:1%; white-space:nowrap;">Aktion</th>
|
||||
<th data-sort-key="target" style="width:1%; white-space:nowrap;">Ziel</th>
|
||||
<th>Details</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
{% set kind = e['action'].split('.')[-1] %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if avatars.get(e['username']) %}
|
||||
<img class="avatar-sm" src="{{ url_for('static', filename='uploads/avatars/' + avatars[e['username']]) }}" alt="">
|
||||
{% else %}
|
||||
<span class="avatar-sm avatar-placeholder">{{ e['username'][:1]|upper }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% set cat = category_of(kind)|trim %}
|
||||
<tr data-category="{{ cat }}" data-sort-ts="{{ e['ts'] }}" data-sort-user="{{ e['username']|lower }}"
|
||||
data-sort-action="{{ action_labels.get(e['action'], e['action'])|lower }}" data-sort-target="{{ (e['target'] or '')|lower }}">
|
||||
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
|
||||
<td class="cell-name">{{ e['username'] }}</td>
|
||||
<td>
|
||||
<span class="pill action-pill">
|
||||
<span class="pill action-pill action-pill--{{ cat }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
|
||||
{{ action_labels.get(e['action'], e['action']) }}
|
||||
</span>
|
||||
@@ -65,11 +109,54 @@
|
||||
<td class="text-dim">{{ e['details'] or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="6">Noch keine Änderungen protokolliert.</td></tr>
|
||||
<tr class="empty-row"><td colspan="5">Noch keine Änderungen protokolliert.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="auditNoResults" class="text-faint hidden" style="padding:16px; text-align:center; font-size:12.5px;">Keine Einträge für diese Auswahl.</p>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
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();
|
||||
let anyVisible = false;
|
||||
rows.forEach(function (row) {
|
||||
if (row.classList.contains("empty-row")) return;
|
||||
const categoryMatches = !activeCategory || row.dataset.category === activeCategory;
|
||||
const textMatches = !q || row.innerText.toLowerCase().includes(q);
|
||||
const match = categoryMatches && textMatches;
|
||||
row.style.display = match ? "" : "none";
|
||||
if (match) anyVisible = true;
|
||||
});
|
||||
if (noResults) noResults.classList.toggle("hidden", anyVisible);
|
||||
document.querySelectorAll(".stat-card[data-category-filter]").forEach(function (card) {
|
||||
const isTotal = card.dataset.categoryFilter === "";
|
||||
const active = activeCategory === null ? isTotal : card.dataset.categoryFilter === activeCategory;
|
||||
card.classList.toggle("active", active);
|
||||
});
|
||||
}
|
||||
|
||||
if (searchInput) searchInput.addEventListener("input", applyFilters);
|
||||
|
||||
document.querySelectorAll(".stat-card[data-category-filter]").forEach(function (card) {
|
||||
card.addEventListener("click", function () {
|
||||
const key = this.dataset.categoryFilter;
|
||||
activeCategory = (!key || activeCategory === key) ? null : key;
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
|
||||
applyFilters();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Log-Verlauf</h2>
|
||||
<div class="hint">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.</div>
|
||||
<div class="hint">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).</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('logs') }}" class="btn btn-secondary">
|
||||
@@ -16,7 +17,7 @@
|
||||
Zurück zur Live-Ansicht
|
||||
</a>
|
||||
<button type="button" class="btn btn-secondary"
|
||||
onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_file.range_label if selected_file else '' }}')"
|
||||
onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_file.range_label if selected_file else '' }}', true)"
|
||||
{% if not selected_name %}disabled{% endif %}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||
Komplettes Log (RAW)
|
||||
@@ -50,6 +51,55 @@
|
||||
<pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="section-head" style="margin-top:28px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Archivierte Auditlog-Tage</h2>
|
||||
<div class="hint">Ä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.</div>
|
||||
</div>
|
||||
{% if current_user.can_manage_log_history and audit_files %}
|
||||
<form method="post" action="{{ url_for('logs_history_audit_export') }}"
|
||||
data-confirm="Alle {{ audit_files|length }} archivierten Auditlog-Datei(en) als ZIP herunterladen und danach vom Server löschen?"
|
||||
data-confirm-title="Export & Löschen">
|
||||
<button type="submit" class="btn btn-secondary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||
Alle exportieren & löschen (ZIP)
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if disk_warning %}
|
||||
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">{{ disk_warning }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not audit_files %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Noch keine archivierten Auditlog-Tage vorhanden.</p>
|
||||
{% else %}
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th style="width:1%; white-space:nowrap;">Tag</th>
|
||||
<th style="width:1%; white-space:nowrap;">Größe</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for f in audit_files %}
|
||||
<tr>
|
||||
<td class="mono">{{ f.day }}</td>
|
||||
<td class="text-dim mono" style="font-size:12.5px;">{{ (f.size / 1024)|round(1) }} KB</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-secondary btn-sm"
|
||||
onclick="openRawLogModal('{{ url_for('logs_history_audit_raw', file=f.filename) }}', 'Auditlog-Archiv — {{ f.day }}', true)">
|
||||
Ansehen (RAW)
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
|
||||
Reference in New Issue
Block a user