Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b4cf8c2e1 | ||
|
|
1c0082d3d4 | ||
|
|
fc4b165d23 | ||
|
|
e81735d5ec | ||
|
|
2eaefc9e1f | ||
|
|
cb8b929c3c | ||
|
|
af5dfe30b4 | ||
|
|
efb0d2aa01 |
+1
-1
@@ -1 +1 @@
|
||||
1.1.2
|
||||
1.1.9
|
||||
|
||||
+458
-27
@@ -19,7 +19,7 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography import x509
|
||||
from datetime import datetime, timedelta
|
||||
import base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback
|
||||
import base64, csv, io, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback, zipfile
|
||||
import paramiko
|
||||
import yaml
|
||||
import ssl
|
||||
@@ -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,
|
||||
@@ -2289,18 +2473,31 @@ def fileshare_upload():
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
abs_dir = _fileshare_resolve_path(share, rel_path)
|
||||
file = request.files.get("file")
|
||||
if not abs_dir or not os.path.isdir(abs_dir) or not file or not file.filename:
|
||||
# request.files.getlist() statt .get(): das Upload-Feld erlaubt jetzt
|
||||
# Mehrfachauswahl (name="file" multiple) -- ein einzelner Dateiauswahl-
|
||||
# Dialog liefert dann mehrere Files unter demselben Feldnamen, klassisch
|
||||
# eine Datei liefert genauso eine Liste mit einem Element.
|
||||
files = [f for f in request.files.getlist("file") if f and f.filename]
|
||||
if not abs_dir or not os.path.isdir(abs_dir) or not files:
|
||||
flash("Ungültiges Ziel oder keine Datei ausgewählt.", "danger")
|
||||
else:
|
||||
uploaded, rejected = [], []
|
||||
for file in files:
|
||||
filename = secure_filename(file.filename)
|
||||
dest = os.path.join(abs_dir, filename) if filename else None
|
||||
if not filename or os.path.dirname(os.path.realpath(dest)) != os.path.realpath(abs_dir):
|
||||
flash("Ungültiger Dateiname.", "danger")
|
||||
else:
|
||||
rejected.append(file.filename)
|
||||
continue
|
||||
file.save(dest)
|
||||
log_action("fileshare.upload", share, f"{rel_path}/{filename}".strip("/"))
|
||||
flash(f"„{filename}“ hochgeladen.", "success")
|
||||
uploaded.append(filename)
|
||||
if uploaded:
|
||||
log_action("fileshare.upload", share, f"{rel_path}/".strip("/") + f" ({len(uploaded)} Datei(en): {', '.join(uploaded)})")
|
||||
if len(uploaded) == 1:
|
||||
flash(f"„{uploaded[0]}“ hochgeladen.", "success")
|
||||
else:
|
||||
flash(f"{len(uploaded)} Dateien hochgeladen: {', '.join(uploaded)}.", "success")
|
||||
if rejected:
|
||||
flash(f"Ungültiger Dateiname, übersprungen: {', '.join(rejected)}.", "danger")
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
|
||||
@@ -2327,6 +2524,21 @@ def fileshare_mkdir():
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
|
||||
def _fileshare_resolve_child(share, rel_path, name):
|
||||
"""Löst EINEN Kind-Eintrag (Datei oder Ordner) von rel_path auf und
|
||||
stellt zusätzlich sicher, dass er auch tatsächlich direkt DARIN liegt
|
||||
(per os.sep-Präfix-Vergleich des bereits Path-Traversal-geprüften
|
||||
_fileshare_resolve_path) -- von delete/delete-multi/download-multi
|
||||
gemeinsam genutzt. None bei jedem ungültigen Fall, wirft nie."""
|
||||
if not name:
|
||||
return None
|
||||
parent_abs = _fileshare_resolve_path(share, rel_path)
|
||||
target_abs = _fileshare_resolve_path(share, f"{rel_path}/{name}".strip("/"))
|
||||
if not parent_abs or not target_abs or not target_abs.startswith(parent_abs + os.sep):
|
||||
return None
|
||||
return target_abs
|
||||
|
||||
|
||||
@app.route("/fileshare/delete", methods=["POST"])
|
||||
@login_required
|
||||
def fileshare_delete():
|
||||
@@ -2337,9 +2549,8 @@ def fileshare_delete():
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
name = request.form.get("name", "")
|
||||
parent_abs = _fileshare_resolve_path(share, rel_path)
|
||||
target_abs = _fileshare_resolve_path(share, f"{rel_path}/{name}".strip("/")) if name else None
|
||||
if not parent_abs or not target_abs or not target_abs.startswith(parent_abs + os.sep):
|
||||
target_abs = _fileshare_resolve_child(share, rel_path, name)
|
||||
if not target_abs:
|
||||
flash("Ungültiges Ziel.", "danger")
|
||||
else:
|
||||
try:
|
||||
@@ -2354,6 +2565,99 @@ def fileshare_delete():
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
|
||||
@app.route("/fileshare/delete-multi", methods=["POST"])
|
||||
@login_required
|
||||
def fileshare_delete_multi():
|
||||
"""Wie /fileshare/delete, nur für eine per Checkbox ausgewählte Menge
|
||||
an Dateien/Ordnern auf einmal (Mehrfachauswahl in der Tabelle)."""
|
||||
share = request.form.get("share", "")
|
||||
rel_path = request.form.get("path", "")
|
||||
if not current_user.has_permission("fileshare.edit"):
|
||||
flash("Keine Berechtigung, Dateien/Ordner zu löschen.", "danger")
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
names = [n for n in request.form.getlist("names") if n]
|
||||
if not names:
|
||||
flash("Keine Elemente ausgewählt.", "danger")
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
deleted, failed = [], []
|
||||
for name in names:
|
||||
target_abs = _fileshare_resolve_child(share, rel_path, name)
|
||||
if not target_abs:
|
||||
failed.append(name)
|
||||
continue
|
||||
try:
|
||||
if os.path.isdir(target_abs):
|
||||
shutil.rmtree(target_abs)
|
||||
else:
|
||||
os.remove(target_abs)
|
||||
deleted.append(name)
|
||||
except OSError:
|
||||
failed.append(name)
|
||||
|
||||
if deleted:
|
||||
log_action("fileshare.delete", share, f"{rel_path}/".strip("/") + f" ({len(deleted)} Element(e): {', '.join(deleted)})")
|
||||
flash(f"{len(deleted)} Element(e) gelöscht: {', '.join(deleted)}.", "success")
|
||||
if failed:
|
||||
flash(f"Löschen fehlgeschlagen für: {', '.join(failed)}.", "danger")
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
|
||||
@app.route("/fileshare/download-multi", methods=["POST"])
|
||||
@login_required
|
||||
def fileshare_download_multi():
|
||||
"""Baut eine Auswahl aus mehreren Dateien/Ordnern zu EINEM ZIP zusammen
|
||||
-- vermeidet, dass der Browser bei vielen einzelnen Downloads auf
|
||||
einmal blockiert/nachfragt, und ist die auch anderswo (Drive, Nextcloud
|
||||
etc.) übliche Erwartung bei Mehrfachauswahl. Ordner werden rekursiv mit
|
||||
aufgenommen (relativer Pfad innerhalb des Ordners als Archivpfad)."""
|
||||
share = request.form.get("share", "")
|
||||
rel_path = request.form.get("path", "")
|
||||
if not current_user.has_permission("fileshare.view"):
|
||||
return "Keine Berechtigung.", 403
|
||||
|
||||
names = [n for n in request.form.getlist("names") if n]
|
||||
if not names:
|
||||
flash("Keine Elemente ausgewählt.", "danger")
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
targets = []
|
||||
skipped = []
|
||||
for name in names:
|
||||
target_abs = _fileshare_resolve_child(share, rel_path, name)
|
||||
if not target_abs or not os.path.exists(target_abs):
|
||||
skipped.append(name)
|
||||
continue
|
||||
targets.append((name, target_abs))
|
||||
if not targets:
|
||||
flash("Keines der ausgewählten Elemente konnte gefunden werden.", "danger")
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
if skipped:
|
||||
flash(f"Übersprungen (nicht gefunden): {', '.join(skipped)}.", "danger")
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, target_abs in targets:
|
||||
if os.path.isdir(target_abs):
|
||||
for root, _dirs, files in os.walk(target_abs):
|
||||
for fname in files:
|
||||
full = os.path.join(root, fname)
|
||||
arcname = os.path.join(name, os.path.relpath(full, target_abs))
|
||||
zf.write(full, arcname)
|
||||
else:
|
||||
zf.write(target_abs, name)
|
||||
buffer.seek(0)
|
||||
|
||||
if len(targets) == 1:
|
||||
base_name, _ext = os.path.splitext(targets[0][0])
|
||||
zip_name = secure_filename(base_name if not os.path.isdir(targets[0][1]) else targets[0][0]) or "Download"
|
||||
else:
|
||||
zip_name = secure_filename(f"{share}-Auswahl") or "Download"
|
||||
log_action("fileshare.download", share, f"{len(targets)} Element(e) als ZIP: {', '.join(n for n, _ in targets)}")
|
||||
return send_file(buffer, as_attachment=True, download_name=f"{zip_name}.zip", mimetype="application/zip")
|
||||
|
||||
|
||||
@app.route("/fileshare/rename", methods=["POST"])
|
||||
@login_required
|
||||
def fileshare_rename():
|
||||
@@ -7246,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -7267,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():
|
||||
@@ -7315,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
|
||||
@@ -7334,25 +7706,84 @@ 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():
|
||||
"""Änderungslog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen,
|
||||
Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts — die stehen im Live-Log."""
|
||||
"""Auditlog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen,
|
||||
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 Ä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"
|
||||
"SELECT id, ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT ?",
|
||||
(AUDIT_LOG_PAGE_SIZE,),
|
||||
).fetchall()
|
||||
avatars = {
|
||||
row["username"]: row["avatar_filename"]
|
||||
for row in conn.execute("SELECT username, avatar_filename FROM users").fetchall()
|
||||
}
|
||||
# Gesamtzahlen je Kategorie (nicht nur der geladenen Zeilen) für die
|
||||
# "X / Y geladen"-Anzeige in den Statistik-Kacheln -- eine einzige
|
||||
# aggregierte Abfrage statt die komplette Tabelle einzulesen. Die
|
||||
# LIKE-Muster bilden exakt dieselbe Kategorisierung wie category_of()
|
||||
# in _audit_log_macros.html ab (kind = action.split(".")[-1]).
|
||||
totals_row = conn.execute(
|
||||
"SELECT COUNT(*) AS total, "
|
||||
"SUM(CASE WHEN action LIKE '%.create' OR action LIKE '%.upload' OR action LIKE '%.mkdir' THEN 1 ELSE 0 END) AS create_n, "
|
||||
"SUM(CASE WHEN action LIKE '%.delete' THEN 1 ELSE 0 END) AS delete_n "
|
||||
"FROM audit_log"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return render_template("activity_log.html", entries=entries, avatars=avatars)
|
||||
|
||||
total_count = totals_row["total"] or 0
|
||||
total_create = totals_row["create_n"] or 0
|
||||
total_delete = totals_row["delete_n"] or 0
|
||||
total_edit = total_count - total_create - total_delete
|
||||
|
||||
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,
|
||||
total_create=total_create, total_edit=total_edit, total_delete=total_delete,
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
========================================================================== */
|
||||
@@ -1360,6 +1403,24 @@ select {
|
||||
.xlsx-preview-sheet-title { margin: 20px 0 8px; font-size: 13px; font-weight: 650; }
|
||||
.xlsx-preview-sheet-title:first-child { margin-top: 0; }
|
||||
|
||||
/* Angesammelte Dateien im Upload-Modal (Mehrfachauswahl, siehe fileshare.html) */
|
||||
.upload-file-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.upload-file-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 5px 10px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 7px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.upload-file-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.upload-file-remove {
|
||||
flex-shrink: 0;
|
||||
border: none; background: transparent; color: var(--text-faint);
|
||||
font-size: 16px; line-height: 1; cursor: pointer; padding: 0 2px;
|
||||
}
|
||||
.upload-file-remove:hover { color: var(--danger); }
|
||||
|
||||
/* ==========================================================================
|
||||
Utilities
|
||||
========================================================================== */
|
||||
@@ -1404,6 +1465,12 @@ select {
|
||||
Buttons/Suchfeld ineinanderzuschieben. */
|
||||
.modal-footer { flex-wrap: wrap; }
|
||||
.table-toolbar .search-input { min-width: 0; flex: 1 1 160px; }
|
||||
|
||||
/* Fileshare-Baum + Tabelle nebeneinander sprengt auf Tablet-/Handy-
|
||||
Breite die Seite (Baum-Spalte ist fest 260px breit) -- Baum stapelt
|
||||
stattdessen oben, Tabelle darunter in voller Breite. */
|
||||
.fileshare-layout { flex-direction: column; }
|
||||
.fileshare-tree { flex: 1 1 auto; max-width: 100%; max-height: 240px; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -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."; });
|
||||
};
|
||||
|
||||
|
||||
@@ -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": '<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"/>',
|
||||
"create": '<path d="M12 5v14M5 12h14"/>',
|
||||
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/>',
|
||||
"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 %}
|
||||
|
||||
{% macro audit_row(e) %}
|
||||
{% set kind = e['action'].split('.')[-1] %}
|
||||
{% 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 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>
|
||||
</td>
|
||||
<td>{{ e['target'] or '—' }}</td>
|
||||
<td class="text-dim">{{ e['details'] or '—' }}</td>
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,5 @@
|
||||
{# Reines Zeilenfragment fürs AJAX-Nachladen (activity_log_more()) -- kein
|
||||
umschließendes <table>/<tbody>, 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 %}
|
||||
@@ -1,75 +1,224 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "logs" %}
|
||||
{% block page_title %}Änderungen{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %}
|
||||
{% block page_title %}Auditlog{% endblock %}
|
||||
{% block page_sub %}
|
||||
<div class="topbar-sub" id="auditEntryCount">
|
||||
{% if has_more %}{{ entries|length }} von {{ total_count }} Einträgen geladen{% else %}{{ entries|length }} Eintrag{{ 'e' if entries|length != 1 else '' }}{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_audit_log_macros.html" as m %}
|
||||
|
||||
{% 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",
|
||||
} %}
|
||||
{% 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"/>',
|
||||
"create": '<path d="M12 5v14M5 12h14"/>',
|
||||
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/>',
|
||||
"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 ns = namespace(create=0, delete=0, edit=0) %}
|
||||
{% for e in entries %}
|
||||
{% 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 %}
|
||||
{% endfor %}
|
||||
|
||||
{% macro fmt_count(loaded, total) %}{% if loaded < total %}{{ loaded }} / {{ total }}{% else %}{{ loaded }}{% endif %}{% endmacro %}
|
||||
|
||||
<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" id="statAll">{{ fmt_count(entries|length, total_count) }}</div>
|
||||
</div>
|
||||
<div class="stat-card" data-category-filter="create">
|
||||
<div class="stat-label">Hinzufügen</div>
|
||||
<div class="stat-value" id="statCreate" style="color:var(--success);">{{ fmt_count(ns.create, total_create) }}</div>
|
||||
</div>
|
||||
<div class="stat-card" data-category-filter="edit">
|
||||
<div class="stat-label">Änderungen</div>
|
||||
<div class="stat-value" id="statEdit" style="color:var(--accent-strong);">{{ fmt_count(ns.edit, total_edit) }}</div>
|
||||
</div>
|
||||
<div class="stat-card" data-category-filter="delete">
|
||||
<div class="stat-label">Löschungen</div>
|
||||
<div class="stat-value" id="statDelete" style="color:var(--danger);">{{ fmt_count(ns.delete, total_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>
|
||||
<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">
|
||||
<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>
|
||||
</td>
|
||||
<td>{{ e['target'] or '—' }}</td>
|
||||
<td class="text-dim">{{ e['details'] or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="6">Noch keine Änderungen protokolliert.</td></tr>
|
||||
{% for e in entries %}{{ m.audit_row(e) }}{% else %}
|
||||
<tr class="empty-row"><td colspan="5">Noch keine Änderungen protokolliert.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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 id="auditLoadMoreWrap" class="{{ 'hidden' if not has_more }}" style="padding:16px; text-align:center; display:flex; gap:8px; justify-content:center;">
|
||||
<button type="button" id="auditLoadMoreBtn" class="btn btn-primary" data-oldest-id="{{ oldest_loaded_id or '' }}">
|
||||
Mehr laden (300)
|
||||
</button>
|
||||
<button type="button" id="auditLoadAllBtn" class="btn btn-secondary">
|
||||
Alle laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const searchInput = document.getElementById("auditSearch");
|
||||
const noResults = document.getElementById("auditNoResults");
|
||||
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;
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
/* ---- Nachladen älterer Einträge (Mehr laden / Alle laden) ---- */
|
||||
const totalCount = {{ total_count }};
|
||||
const totalCreate = {{ total_create }};
|
||||
const totalEdit = {{ total_edit }};
|
||||
const totalDelete = {{ total_delete }};
|
||||
const entryCountLabel = document.getElementById("auditEntryCount");
|
||||
|
||||
function fmtCount(loaded, total) {
|
||||
return loaded < total ? loaded + " / " + total : String(loaded);
|
||||
}
|
||||
|
||||
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 = fmtCount(rows.length, totalCount);
|
||||
document.getElementById("statCreate").textContent = fmtCount(counts.create, totalCreate);
|
||||
document.getElementById("statEdit").textContent = fmtCount(counts.edit, totalEdit);
|
||||
document.getElementById("statDelete").textContent = fmtCount(counts.delete, totalDelete);
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -66,10 +66,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bulkActionsBar" class="flex gap-2 hidden" data-share="{{ selected_share }}" data-path="{{ rel_path }}" style="align-items:center; margin-bottom:10px; flex-wrap:wrap;">
|
||||
<span id="bulkSelectedCount" class="text-faint" style="font-size:12.5px; font-weight:600;"></span>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkDownload()">
|
||||
<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>
|
||||
Herunterladen (ZIP)
|
||||
</button>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);" onclick="bulkDelete()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>
|
||||
Löschen
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if entries %}
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="fileTable" data-sortable>
|
||||
<thead><tr>
|
||||
<th style="width:1%;"><input type="checkbox" id="selectAllFiles"></th>
|
||||
<th data-sort-key="name">Name</th>
|
||||
<th data-sort-key="size">Größe</th>
|
||||
<th data-sort-key="mtime">Geändert</th>
|
||||
@@ -78,6 +93,7 @@
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr data-sort-name="{{ e.name|lower }}" data-sort-size="{{ 0 if e.is_dir else e.size_str }}" data-sort-mtime="{{ e.mtime_str }}">
|
||||
<td><input type="checkbox" class="row-select" value="{{ e.name }}"></td>
|
||||
<td class="cell-name">
|
||||
{% if e.is_dir %}
|
||||
<a href="{{ url_for('fileshare', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||
@@ -140,14 +156,15 @@
|
||||
<input type="hidden" name="share" value="{{ selected_share }}">
|
||||
<input type="hidden" name="path" value="{{ rel_path }}">
|
||||
<div class="modal-header">
|
||||
<h3>Datei hochladen</h3>
|
||||
<h3>Datei(en) hochladen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label>Datei</label>
|
||||
<input type="file" name="file" required>
|
||||
<div class="field-hint">Maximal 15 MB pro Datei.</div>
|
||||
<label>Datei(en)</label>
|
||||
<input type="file" name="file" id="uploadFileInput" multiple required>
|
||||
<div id="uploadFileList" class="upload-file-list"></div>
|
||||
<div class="field-hint">Insgesamt maximal 15 MB pro Upload-Vorgang. Mehrfachauswahl möglich (auch mehrmals nacheinander — bereits hinzugefügte Dateien bleiben dabei erhalten).</div>
|
||||
</div>
|
||||
<div class="field-hint">Wird in „{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}“ hochgeladen. Eine bereits vorhandene Datei gleichen Namens wird überschrieben.</div>
|
||||
</div>
|
||||
@@ -227,6 +244,8 @@
|
||||
<script>
|
||||
const FILESHARE_SUBFOLDERS_URL = "{{ url_for('fileshare_subfolders') }}";
|
||||
const FILESHARE_BASE_URL = "{{ url_for('fileshare') }}";
|
||||
const FILESHARE_DOWNLOAD_MULTI_URL = "{{ url_for('fileshare_download_multi') }}";
|
||||
const FILESHARE_DELETE_MULTI_URL = "{{ url_for('fileshare_delete_multi') }}";
|
||||
|
||||
function openRenameModal(name) {
|
||||
document.getElementById("renameOldName").value = name;
|
||||
@@ -242,6 +261,152 @@ function filterTable(inputId, tableId) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- Mehrfachauswahl (Herunterladen als ZIP / Löschen) ---------------- */
|
||||
|
||||
function getSelectedFileNames() {
|
||||
return Array.from(document.querySelectorAll("#fileTable .row-select:checked")).map(function (cb) { return cb.value; });
|
||||
}
|
||||
|
||||
function updateBulkBar() {
|
||||
const names = getSelectedFileNames();
|
||||
const bar = document.getElementById("bulkActionsBar");
|
||||
const countEl = document.getElementById("bulkSelectedCount");
|
||||
if (!bar) return;
|
||||
if (names.length > 0) {
|
||||
bar.classList.remove("hidden");
|
||||
countEl.textContent = names.length + " ausgewählt";
|
||||
} else {
|
||||
bar.classList.add("hidden");
|
||||
}
|
||||
const selectAll = document.getElementById("selectAllFiles");
|
||||
const allBoxes = document.querySelectorAll("#fileTable .row-select");
|
||||
if (selectAll && allBoxes.length) {
|
||||
selectAll.checked = names.length === allBoxes.length;
|
||||
selectAll.indeterminate = names.length > 0 && names.length < allBoxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function submitBulkForm(actionUrl, names) {
|
||||
// data-share/data-path statt Jinja-Werte direkt in einen JS-String-
|
||||
// Literal zu setzen -- Ordnernamen kommen von der echten Freigabe und
|
||||
// koennten Anfuehrungszeichen o.ae. enthalten, ueber HTML-Attribute
|
||||
// (von Jinja automatisch escaped) ist das unproblematisch.
|
||||
const bar = document.getElementById("bulkActionsBar");
|
||||
const form = document.createElement("form");
|
||||
form.method = "post";
|
||||
form.action = actionUrl;
|
||||
form.style.display = "none";
|
||||
[["share", bar.dataset.share], ["path", bar.dataset.path]].forEach(function (pair) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = pair[0];
|
||||
input.value = pair[1];
|
||||
form.appendChild(input);
|
||||
});
|
||||
names.forEach(function (name) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = "names";
|
||||
input.value = name;
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function bulkDownload() {
|
||||
const names = getSelectedFileNames();
|
||||
if (!names.length) return;
|
||||
submitBulkForm(FILESHARE_DOWNLOAD_MULTI_URL, names);
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
const names = getSelectedFileNames();
|
||||
if (!names.length) return;
|
||||
window.confirmAction(
|
||||
names.length + " ausgewählte Elemente wirklich endgültig löschen?",
|
||||
function () { submitBulkForm(FILESHARE_DELETE_MULTI_URL, names); },
|
||||
"Auswahl löschen?"
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const selectAll = document.getElementById("selectAllFiles");
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener("change", function () {
|
||||
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) { cb.checked = selectAll.checked; });
|
||||
updateBulkBar();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) {
|
||||
cb.addEventListener("change", updateBulkBar);
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------------- Mehrfach-Upload (Multiauswahl + mehrmals nacheinander) ---------------- */
|
||||
/* Ein <input type=file multiple> ERSETZT bei jeder erneuten Dateiauswahl
|
||||
die vorherige -- fuer "mehrmals nacheinander hinzufuegen" wird deshalb
|
||||
selbst eine "angesammelte" Auswahl per DataTransfer gepflegt und nach
|
||||
jeder Aenderung zurueck auf das Input-Feld geschrieben, sodass das
|
||||
normale <form>-Submit (kein fetch() noetig) am Ende alle gesammelten
|
||||
Dateien mitschickt. DataTransfer-Zuweisung an .files wird von allen
|
||||
gaengigen Mobil-Browsern (Android Chrome, iOS Safari) mitgetragen; falls
|
||||
nicht, faellt es einfach auf das native Verhalten (letzte Auswahl zaehlt)
|
||||
zurueck, ohne den Upload an sich zu verhindern. */
|
||||
(function () {
|
||||
const input = document.getElementById("uploadFileInput");
|
||||
const listEl = document.getElementById("uploadFileList");
|
||||
if (!input || !listEl) return;
|
||||
let staged = null;
|
||||
try { staged = new DataTransfer(); } catch (e) { staged = null; }
|
||||
|
||||
function render() {
|
||||
listEl.innerHTML = "";
|
||||
if (!staged) return;
|
||||
Array.from(staged.files).forEach(function (file, idx) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "upload-file-row";
|
||||
const name = document.createElement("span");
|
||||
name.textContent = file.name;
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.className = "upload-file-remove";
|
||||
removeBtn.setAttribute("aria-label", "Entfernen");
|
||||
removeBtn.textContent = "×";
|
||||
removeBtn.addEventListener("click", function () {
|
||||
const dt = new DataTransfer();
|
||||
Array.from(staged.files).forEach(function (f, i) {
|
||||
if (i !== idx) dt.items.add(f);
|
||||
});
|
||||
staged = dt;
|
||||
input.files = staged.files;
|
||||
render();
|
||||
});
|
||||
row.appendChild(name);
|
||||
row.appendChild(removeBtn);
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener("change", function () {
|
||||
if (!staged) return; // kein DataTransfer-Support -- natives Verhalten greift
|
||||
Array.from(input.files).forEach(function (file) { staged.items.add(file); });
|
||||
input.files = staged.files;
|
||||
render();
|
||||
});
|
||||
|
||||
// Beim (Wieder-)Oeffnen des Modals eine frische Sammlung starten, statt
|
||||
// Dateien aus einem vorherigen, bereits abgeschickten Upload-Vorgang
|
||||
// versehentlich mitzuschleppen.
|
||||
document.querySelectorAll('[data-open-modal="uploadModal"]').forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
try { staged = new DataTransfer(); } catch (e) { staged = null; }
|
||||
input.value = "";
|
||||
render();
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
/* ---------------- Baum-Navigation (Freigaben links) ---------------- */
|
||||
|
||||
function buildTreeNode(share, path, name) {
|
||||
|
||||
@@ -104,8 +104,8 @@
|
||||
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="icon-btn" title="Rechte anzeigen" onclick="toggleDetail('detail-admin')">
|
||||
<svg id="chev-admin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
||||
<button class="icon-btn" title="Rechte anzeigen" data-open-modal="adminGroupModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="adminMembersModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
||||
@@ -113,16 +113,11 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="group-detail-row hidden" id="detail-admin">
|
||||
<td colspan="3">
|
||||
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
|
||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
{% for g in groups %}
|
||||
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
||||
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
||||
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
|
||||
<tr>
|
||||
<td class="cell-name">
|
||||
@@ -132,8 +127,12 @@
|
||||
<td class="text-dim">{{ g.member_names|length }}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="icon-btn" title="Rechte anzeigen{{ '/bearbeiten' if can_edit_this else '' }}" onclick="toggleDetail('detail-{{ g.id }}')">
|
||||
<svg id="chev-{{ g.id }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
||||
<button class="icon-btn" title="{{ 'Bearbeiten' if (can_edit_this or can_unlock_system) else 'Anzeigen' }}" data-open-modal="editGroupModal{{ loop.index }}">
|
||||
{% if can_edit_this or can_unlock_system %}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
{% else %}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
{% endif %}
|
||||
</button>
|
||||
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
||||
@@ -149,57 +148,6 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
||||
<tr class="group-detail-row hidden" id="detail-{{ g.id }}">
|
||||
<td colspan="3">
|
||||
{% if can_edit_this %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="save_group" value="1">
|
||||
<input type="hidden" name="permissions_submitted" value="1">
|
||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||
<input type="hidden" name="name" value="{{ g.name }}">
|
||||
{{ permission_tree(g.permissions, false, true) }}
|
||||
<div class="flex" style="justify-content:flex-end; margin-top:16px;">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
Rechte speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% elif can_unlock_system %}
|
||||
<div id="readonly-{{ g.id }}">
|
||||
{{ permission_tree(g.permissions, true, true) }}
|
||||
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px;">
|
||||
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="hidden" id="unlock-{{ g.id }}">
|
||||
<input type="hidden" name="save_group" value="1">
|
||||
<input type="hidden" name="permissions_submitted" value="1">
|
||||
<input type="hidden" name="unlock_system_group" value="1">
|
||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||
<input type="hidden" name="name" value="{{ g.name }}">
|
||||
{{ permission_tree(g.permissions, false, true) }}
|
||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
|
||||
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
|
||||
Rechte hier können den Erst-Login neuer Konten einschränken.
|
||||
</p>
|
||||
<div class="flex" style="justify-content:flex-end;">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
Rechte speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
{{ permission_tree(g.permissions, true, true) }}
|
||||
{% if g.is_system %}
|
||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{% else %}
|
||||
<tbody data-sort-pinned>
|
||||
@@ -210,6 +158,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="adminGroupModal">
|
||||
<div class="modal" style="max-width:1000px;">
|
||||
<div class="modal-header">
|
||||
<h3>Admin — Rechte</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
|
||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="adminMembersModal">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<form method="post">
|
||||
@@ -238,6 +199,8 @@
|
||||
</div>
|
||||
|
||||
{% for g in groups %}
|
||||
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
||||
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
||||
<div class="modal-overlay" id="membersModal{{ loop.index }}">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<form method="post">
|
||||
@@ -268,6 +231,80 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="editGroupModal{{ loop.index }}">
|
||||
<div class="modal" style="max-width:1000px;">
|
||||
{% if can_edit_this %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="save_group" value="1">
|
||||
<input type="hidden" name="permissions_submitted" value="1">
|
||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||
<div class="modal-header">
|
||||
<h3>Gruppe bearbeiten</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" value="{{ g.name }}" required>
|
||||
</div>
|
||||
{{ permission_tree(g.permissions, false, true) }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% elif can_unlock_system %}
|
||||
<div class="modal-header">
|
||||
<h3>Gruppe „{{ g.name }}“</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Name</label><input type="text" value="{{ g.name }}" disabled></div>
|
||||
<div id="readonly-{{ g.id }}">
|
||||
{{ permission_tree(g.permissions, true, true) }}
|
||||
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px; flex-wrap:wrap;">
|
||||
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="hidden" id="unlock-{{ g.id }}">
|
||||
<input type="hidden" name="save_group" value="1">
|
||||
<input type="hidden" name="permissions_submitted" value="1">
|
||||
<input type="hidden" name="unlock_system_group" value="1">
|
||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||
<input type="hidden" name="name" value="{{ g.name }}">
|
||||
{{ permission_tree(g.permissions, false, true) }}
|
||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
|
||||
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
|
||||
Rechte hier können den Erst-Login neuer Konten einschränken.
|
||||
</p>
|
||||
<div class="flex" style="justify-content:flex-end;">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
Rechte speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="modal-header">
|
||||
<h3>Gruppe „{{ g.name }}“</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ permission_tree(g.permissions, true, true) }}
|
||||
{% if g.is_system %}
|
||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="modal-overlay" id="addGroupModal">
|
||||
@@ -317,14 +354,6 @@ function unlockSystemGroup(id) {
|
||||
);
|
||||
}
|
||||
|
||||
function toggleDetail(id) {
|
||||
const row = document.getElementById(id);
|
||||
if (!row) return;
|
||||
row.classList.toggle("hidden");
|
||||
const chev = document.getElementById(id.replace("detail-", "chev-"));
|
||||
if (chev) chev.style.transform = row.classList.contains("hidden") ? "" : "rotate(180deg)";
|
||||
}
|
||||
|
||||
function applyPermissionGating() {
|
||||
document.querySelectorAll(".permission-group-col").forEach(function (area) {
|
||||
const toggle = area.querySelector(".permission-area-toggle-cb");
|
||||
|
||||
@@ -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