Compare commits
7
Commits
v1.1.5
..
2b05d012ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b05d012ee | ||
|
|
0f0fa99e53 | ||
|
|
9b4cf8c2e1 | ||
|
|
1c0082d3d4 | ||
|
|
fc4b165d23 | ||
|
|
e81735d5ec | ||
|
|
2eaefc9e1f |
+1
-1
@@ -1 +1 @@
|
|||||||
1.1.5
|
1.1.11
|
||||||
|
|||||||
+499
-49
@@ -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_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")
|
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")
|
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:
|
try:
|
||||||
os.makedirs(TESM_LOG_DIR, exist_ok=True)
|
os.makedirs(TESM_LOG_DIR, exist_ok=True)
|
||||||
# War früher 0o777, als noch nicht jeder schreibende Prozess (tesm.service,
|
# War früher 0o777, als noch nicht jeder schreibende Prozess (tesm.service,
|
||||||
@@ -281,8 +293,8 @@ PERMISSIONS = {
|
|||||||
"view_key": "logs_group.view",
|
"view_key": "logs_group.view",
|
||||||
"children": {
|
"children": {
|
||||||
"logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}},
|
"logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}},
|
||||||
"logs_history": {"label": "Verlauf", "rows": {"view": "logs_history.view"}},
|
"logs_history": {"label": "Verlauf", "rows": {"view": "logs_history.view", "edit": "logs_history.edit"}},
|
||||||
"logs_activity": {"label": "Änderungen", "rows": {"view": "logs_activity.view"}},
|
"logs_activity": {"label": "Auditlog", "rows": {"view": "logs_activity.view"}},
|
||||||
"logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.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)",
|
"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.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)",
|
"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_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.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
|
||||||
"users.edit": "Benutzer ändern (inkl. Löschen)",
|
"users.edit": "Benutzer ändern (inkl. Löschen)",
|
||||||
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
|
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
|
||||||
@@ -434,7 +447,7 @@ NAV_ITEMS = [
|
|||||||
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
|
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
|
||||||
{"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
|
{"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
|
||||||
{"key": "logs_history", "label": "Verlauf", "icon": "clock", "endpoint": "logs_history"},
|
{"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"},
|
{"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."""
|
automatisch auch durch ältere Logstände blättern zu können."""
|
||||||
return self.has_permission("logs_history.view")
|
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
|
@property
|
||||||
def can_view_activity_log(self):
|
def can_view_activity_log(self):
|
||||||
return self.has_permission("logs_activity.view")
|
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
|
"""Spiegelt jeden Änderungslog-Eintrag zusätzlich in eine eigene, per
|
||||||
logrotate rotierte Datei (TESM_CHANGES_LOG_PATH) — die audit_log-Tabelle
|
logrotate rotierte Datei (TESM_CHANGES_LOG_PATH) — die audit_log-Tabelle
|
||||||
bleibt die Quelle für die durchsuchbare/sortierbare Seite unter
|
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.
|
dieselbe wöchentliche Rotation/Aufbewahrung wie die anderen drei Logs.
|
||||||
Ein Schreibfehler hier darf den eigentlichen, DB-basierten Audit-Trail
|
Ein Schreibfehler hier darf den eigentlichen, DB-basierten Audit-Trail
|
||||||
nicht gefährden — daher bewusst best-effort mit breitem except."""
|
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)
|
_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):
|
def touch_record(conn, table, key_col, key_val):
|
||||||
"""Trägt 'zuletzt geändert von/am' direkt am Datensatz ein (Devices/Switches)."""
|
"""Trägt 'zuletzt geändert von/am' direkt am Datensatz ein (Devices/Switches)."""
|
||||||
who = current_user.username if current_user.is_authenticated else "system"
|
who = current_user.username if current_user.is_authenticated else "system"
|
||||||
@@ -1285,7 +1450,7 @@ LOG_ROTATION_DEFAULT_KEEP = 4
|
|||||||
|
|
||||||
def _write_logrotate_config():
|
def _write_logrotate_config():
|
||||||
"""Schreibt/aktualisiert die logrotate-Konfiguration für alle vier
|
"""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
|
unter TESM_KEA_LOG_PATH — Keas AppArmor-Profil erlaubt nur genau diesen
|
||||||
einen Pfad, siehe Kommentar bei TESM_KEA_LOG_PATH) anhand der aktuellen
|
einen Pfad, siehe Kommentar bei TESM_KEA_LOG_PATH) anhand der aktuellen
|
||||||
Einstellungen (Systemeinstellungen → Logs). Läuft sowohl beim App-Start
|
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()
|
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():
|
def _current_fileshare_mounts():
|
||||||
"""Für die Fileshare-Seite und die Nav-Sichtbarkeit: gemountete
|
"""Für die Fileshare-Seite und die Nav-Sichtbarkeit: gemountete
|
||||||
Freigaben der AKTUELLEN Session, oder eine leere Liste (lokale Nutzer,
|
Freigaben der AKTUELLEN Session, oder eine leere Liste (lokale Nutzer,
|
||||||
@@ -5208,47 +5392,99 @@ def save_nav_order():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _read_known_hosts_content():
|
||||||
|
"""Für den Export: der Inhalt von known_hosts gehört inhaltlich zu den
|
||||||
|
Zugangsdaten (jeder per SSH bereits bestätigte Host-Key eines Geräts/
|
||||||
|
Switches, das über gespeicherte Zugangsdaten erreicht wurde) -- landet
|
||||||
|
daher NICHT als eigene EXPORT_BUILDERS-Kategorie, sondern als
|
||||||
|
zusätzlicher Payload-Schlüssel "known_hosts", sobald "credentials"
|
||||||
|
mitexportiert wird (siehe export_data())."""
|
||||||
|
try:
|
||||||
|
with open(SSH_KNOWN_HOSTS_PATH, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
return content if content.strip() else None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_known_hosts_content(new_content):
|
||||||
|
"""Für den Import: ergänzt die eigene known_hosts-Datei um die im
|
||||||
|
Export enthaltenen Zeilen -- bewusst ergänzend statt überschreibend
|
||||||
|
(die Zielumgebung kann bereits eigene, unabhängig bestätigte Host-Keys
|
||||||
|
haben) und mit Deduplizierung nach vollständiger Zeile, damit ein
|
||||||
|
wiederholter Import derselben Exportdatei nicht bei jedem Mal weitere
|
||||||
|
Duplikate anhäuft. Gibt die Anzahl tatsächlich neu hinzugefügter
|
||||||
|
Zeilen zurück."""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
with open(SSH_KNOWN_HOSTS_PATH, "r", encoding="utf-8") as f:
|
||||||
|
existing_lines = set(f.read().splitlines())
|
||||||
|
except OSError:
|
||||||
|
existing_lines = set()
|
||||||
|
new_lines = [ln for ln in new_content.splitlines() if ln.strip() and ln not in existing_lines]
|
||||||
|
if not new_lines:
|
||||||
|
return 0
|
||||||
|
os.makedirs(os.path.dirname(SSH_KNOWN_HOSTS_PATH) or ".", exist_ok=True)
|
||||||
|
with open(SSH_KNOWN_HOSTS_PATH, "a", encoding="utf-8") as f:
|
||||||
|
f.write("\n".join(new_lines) + "\n")
|
||||||
|
return len(new_lines)
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
EXPORT_SECTIONS = [
|
EXPORT_SECTIONS = [
|
||||||
("devices", "Clients"),
|
("devices", "Clients"),
|
||||||
("switches", "Switche"),
|
("switches", "Switche"),
|
||||||
("credentials", "Zugangsdaten"),
|
("credentials", "Zugangsdaten (inkl. bekannter SSH-Host-Keys aus known_hosts)"),
|
||||||
("users", "Benutzer (lokal)"),
|
("users", "Benutzer (lokal)"),
|
||||||
("groups", "Custom-Gruppen"),
|
("groups", "Custom-Gruppen"),
|
||||||
("ldap", "LDAP/AD-Einstellungen"),
|
("ldap", "LDAP/AD-Einstellungen"),
|
||||||
("dhcp", "DHCP-Einstellungen"),
|
("dhcp", "DHCP-Einstellungen"),
|
||||||
("logs", f"Logs (letzte {500} Einträge)"),
|
("nginx", "NGINX-Einstellungen (Domain, Ports, SSL/HSTS — ohne Zertifikat/Schlüssel)"),
|
||||||
|
("logs", "Auditlog (alle aktuell in der Datenbank vorhandenen Einträge)"),
|
||||||
]
|
]
|
||||||
EXPORT_SECTION_LABELS = dict(EXPORT_SECTIONS)
|
EXPORT_SECTION_LABELS = dict(EXPORT_SECTIONS)
|
||||||
|
|
||||||
IMPORT_EXPORT_ADMIN_ONLY_SECTIONS = {"users", "groups", "ldap"}
|
IMPORT_EXPORT_ADMIN_ONLY_SECTIONS = {"users", "groups", "ldap"}
|
||||||
|
|
||||||
LOG_EXPORT_LIMIT = 500
|
|
||||||
LDAP_SETTING_KEYS = [
|
LDAP_SETTING_KEYS = [
|
||||||
"ldap_enabled", "ldap_server", "ldap_port", "ldap_use_ssl", "ldap_tls_skip_verify",
|
"ldap_enabled", "ldap_server", "ldap_port", "ldap_use_ssl", "ldap_tls_skip_verify",
|
||||||
"ldap_base_dn", "ldap_user_filter_attr", "ldap_default_group",
|
"ldap_base_dn", "ldap_user_filter_attr", "ldap_default_group", "ldap_required_login_group",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _export_devices(conn):
|
def _export_devices(conn):
|
||||||
return [dict(r) for r in conn.execute(
|
return [dict(r) for r in conn.execute("""
|
||||||
"SELECT mac, ip, port, name, switch_hostname, is_active FROM devices WHERE deleted_at IS NULL"
|
SELECT devices.mac, devices.ip, devices.port, devices.name, devices.switch_hostname,
|
||||||
).fetchall()]
|
devices.is_active, devices.ssh_port, credentials.name AS credential_name
|
||||||
|
FROM devices LEFT JOIN credentials ON credentials.id = devices.credential_id
|
||||||
|
WHERE devices.deleted_at IS NULL
|
||||||
|
""").fetchall()]
|
||||||
|
|
||||||
|
|
||||||
def _export_switches(conn):
|
def _export_switches(conn):
|
||||||
return [dict(r) for r in conn.execute("""
|
return [dict(r) for r in conn.execute("""
|
||||||
SELECT switches.hostname, switches.ip, credentials.name AS credential_name
|
SELECT switches.hostname, switches.ip, switches.ssh_port, credentials.name AS credential_name
|
||||||
FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
|
FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
|
||||||
WHERE switches.deleted_at IS NULL
|
WHERE switches.deleted_at IS NULL
|
||||||
""").fetchall()]
|
""").fetchall()]
|
||||||
|
|
||||||
|
|
||||||
def _export_credentials(conn):
|
def _export_credentials(conn):
|
||||||
rows = conn.execute("SELECT name, username, password FROM credentials WHERE deleted_at IS NULL").fetchall()
|
rows = conn.execute("SELECT name, username, password, category FROM credentials WHERE deleted_at IS NULL").fetchall()
|
||||||
return [{"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"])} for r in rows]
|
return [
|
||||||
|
{"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"]), "category": r["category"]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _export_users(conn):
|
def _export_users(conn):
|
||||||
|
"""avatar_filename wird bewusst NICHT exportiert -- das ist nur ein
|
||||||
|
Dateiname, die eigentliche Bilddatei liegt unter static/uploads/avatars
|
||||||
|
auf DIESEM Host und würde auf dem Zielsystem nicht mitkommen; ein
|
||||||
|
exportierter Verweis auf eine dort nicht existierende Datei wäre
|
||||||
|
schlimmer als gar keiner (kaputtes <img>, siehe auch die analoge
|
||||||
|
Begründung bei _export_nginx() für TLS-Zertifikat/Schlüssel)."""
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, username, password, first_name, last_name, email, is_admin, is_locked "
|
"SELECT id, username, password, first_name, last_name, email, is_admin, is_locked "
|
||||||
"FROM users WHERE auth_source='local' AND deleted_at IS NULL"
|
"FROM users WHERE auth_source='local' AND deleted_at IS NULL"
|
||||||
@@ -5269,7 +5505,7 @@ def _export_users(conn):
|
|||||||
|
|
||||||
|
|
||||||
def _export_groups(conn):
|
def _export_groups(conn):
|
||||||
rows = conn.execute("SELECT id, name FROM groups WHERE is_system=0 AND deleted_at IS NULL").fetchall()
|
rows = conn.execute("SELECT id, name, is_default FROM groups WHERE is_system=0 AND deleted_at IS NULL").fetchall()
|
||||||
result = []
|
result = []
|
||||||
for g in rows:
|
for g in rows:
|
||||||
perms = [p["permission"] for p in conn.execute(
|
perms = [p["permission"] for p in conn.execute(
|
||||||
@@ -5280,23 +5516,34 @@ def _export_groups(conn):
|
|||||||
"WHERE ug.group_id=? AND u.deleted_at IS NULL",
|
"WHERE ug.group_id=? AND u.deleted_at IS NULL",
|
||||||
(g["id"],),
|
(g["id"],),
|
||||||
).fetchall()]
|
).fetchall()]
|
||||||
result.append({"name": g["name"], "permissions": perms, "members": members})
|
result.append({"name": g["name"], "is_default": g["is_default"], "permissions": perms, "members": members})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _export_ldap(conn):
|
def _export_ldap(conn):
|
||||||
|
"""app_group_id ist eine rohe, lokale groups.id (oder der Sonderwert
|
||||||
|
"admin") -- über eine Umgebungsgrenze hinweg garantiert NICHT stabil
|
||||||
|
(frische Installation vergibt Gruppen-IDs neu). Exportiert wird daher
|
||||||
|
der Gruppenname (analog _export_switches()' credential_name), _import_ldap
|
||||||
|
löst ihn auf dem Zielsystem wieder zur dortigen ID auf -- gleiches
|
||||||
|
"admin"/g.name-Mapping wie in _ldap_group_mappings() weiter oben."""
|
||||||
settings_data = {k: get_setting(k) for k in LDAP_SETTING_KEYS if get_setting(k) not in (None, "")}
|
settings_data = {k: get_setting(k) for k in LDAP_SETTING_KEYS if get_setting(k) not in (None, "")}
|
||||||
data = {"settings": settings_data, "group_mappings": []}
|
data = {"settings": settings_data, "group_mappings": [], "fileshare_mappings": []}
|
||||||
bind = conn.execute(
|
bind = conn.execute(
|
||||||
"SELECT username, password FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,)
|
"SELECT username, password FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if bind:
|
if bind:
|
||||||
data["bind_username"] = bind["username"]
|
data["bind_username"] = bind["username"]
|
||||||
data["bind_password"] = decrypt_password(bind["password"])
|
data["bind_password"] = decrypt_password(bind["password"])
|
||||||
data["group_mappings"] = [dict(m) for m in conn.execute(
|
data["group_mappings"] = [dict(m) for m in conn.execute("""
|
||||||
"SELECT ad_group_dn, ad_group_name, app_group_id FROM ldap_group_mappings"
|
SELECT m.ad_group_dn, m.ad_group_name,
|
||||||
|
CASE WHEN m.app_group_id = 'admin' THEN 'Admin' ELSE g.name END AS app_group_name
|
||||||
|
FROM ldap_group_mappings m LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL
|
||||||
|
""").fetchall()]
|
||||||
|
data["fileshare_mappings"] = [dict(m) for m in conn.execute(
|
||||||
|
"SELECT ad_group_dn, ad_group_name, share_label, share_unc FROM ldap_fileshare_mappings"
|
||||||
).fetchall()]
|
).fetchall()]
|
||||||
if not (settings_data or "bind_username" in data or data["group_mappings"]):
|
if not (settings_data or "bind_username" in data or data["group_mappings"] or data["fileshare_mappings"]):
|
||||||
return None
|
return None
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -5329,9 +5576,31 @@ def _export_dhcp(conn):
|
|||||||
return {"settings": settings_data, "subnets": subnets, "reservations": reservations, "options": options}
|
return {"settings": settings_data, "subnets": subnets, "reservations": reservations, "options": options}
|
||||||
|
|
||||||
|
|
||||||
|
NGINX_SETTING_KEYS = ["nginx_server_name", "nginx_http_port", "nginx_https_port", "ssl_enabled", "hsts_enabled"]
|
||||||
|
|
||||||
|
|
||||||
|
def _export_nginx(conn):
|
||||||
|
"""Exportiert nur portable Konfiguration (Domain, Ports, SSL/HSTS-
|
||||||
|
Schalter) -- bewusst OHNE ssl_source, TLS-Zertifikat/Schlüssel oder
|
||||||
|
Let's-Encrypt-/certbot-Zustand: das sind entweder host-gebundene reale
|
||||||
|
Dateien (TESM_SSL_CERT_PATH/TESM_SSL_KEY_PATH) oder eine ACME-
|
||||||
|
Registrierung für genau diesen Host/diese Domain, kein portabler
|
||||||
|
Einstellungswert (gleiche Begründung wie bei avatar_filename in
|
||||||
|
_export_users())."""
|
||||||
|
settings_data = {k: get_setting(k) for k in NGINX_SETTING_KEYS if get_setting(k) not in (None, "")}
|
||||||
|
if not settings_data:
|
||||||
|
return None
|
||||||
|
return {"settings": settings_data}
|
||||||
|
|
||||||
|
|
||||||
def _export_logs(conn):
|
def _export_logs(conn):
|
||||||
|
"""Exportiert das komplette, aktuell in der DB stehende Auditlog --
|
||||||
|
bereits archivierte Tage (siehe _archive_old_audit_log_rows) liegen
|
||||||
|
als eigene Dateien unter AUDIT_ARCHIVE_DIR und sind bewusst NICHT Teil
|
||||||
|
dieses Exports, das Auditlog-Export-Formular exportiert nur die
|
||||||
|
Datenbank-Kategorien."""
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT ?", (LOG_EXPORT_LIMIT,)
|
"SELECT ts, username, action, target, details FROM audit_log ORDER BY id DESC"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows] if rows else None
|
return [dict(r) for r in rows] if rows else None
|
||||||
|
|
||||||
@@ -5339,19 +5608,28 @@ def _export_logs(conn):
|
|||||||
EXPORT_BUILDERS = {
|
EXPORT_BUILDERS = {
|
||||||
"devices": _export_devices, "switches": _export_switches, "credentials": _export_credentials,
|
"devices": _export_devices, "switches": _export_switches, "credentials": _export_credentials,
|
||||||
"users": _export_users, "groups": _export_groups, "ldap": _export_ldap, "dhcp": _export_dhcp,
|
"users": _export_users, "groups": _export_groups, "ldap": _export_ldap, "dhcp": _export_dhcp,
|
||||||
"logs": _export_logs,
|
"nginx": _export_nginx, "logs": _export_logs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _import_devices(conn, items):
|
def _import_devices(conn, items):
|
||||||
n = 0
|
n = 0
|
||||||
for d in items:
|
for d in items:
|
||||||
|
cred_id = None
|
||||||
|
if d.get("credential_name"):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM credentials WHERE name=? AND deleted_at IS NULL", (d["credential_name"],)
|
||||||
|
).fetchone()
|
||||||
|
cred_id = row["id"] if row else None
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO devices (mac, ip, port, name, switch_hostname, is_active) VALUES (?, ?, ?, ?, ?, ?)
|
"""INSERT INTO devices (mac, ip, port, name, switch_hostname, is_active, ssh_port, credential_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, port=excluded.port, name=excluded.name,
|
ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, port=excluded.port, name=excluded.name,
|
||||||
switch_hostname=excluded.switch_hostname, is_active=excluded.is_active
|
switch_hostname=excluded.switch_hostname, is_active=excluded.is_active,
|
||||||
|
ssh_port=excluded.ssh_port, credential_id=excluded.credential_id
|
||||||
WHERE devices.deleted_at IS NULL""",
|
WHERE devices.deleted_at IS NULL""",
|
||||||
(d["mac"], d["ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1)),
|
(d["mac"], d["ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1),
|
||||||
|
d.get("ssh_port"), cred_id),
|
||||||
)
|
)
|
||||||
n += 1
|
n += 1
|
||||||
return n
|
return n
|
||||||
@@ -5367,10 +5645,11 @@ def _import_switches(conn, items):
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
cred_id = row["id"] if row else None
|
cred_id = row["id"] if row else None
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?)
|
"""INSERT INTO switches (hostname, ip, ssh_port, credential_id) VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT(hostname) DO UPDATE SET ip=excluded.ip, credential_id=excluded.credential_id
|
ON CONFLICT(hostname) DO UPDATE SET ip=excluded.ip, ssh_port=excluded.ssh_port,
|
||||||
|
credential_id=excluded.credential_id
|
||||||
WHERE switches.deleted_at IS NULL""",
|
WHERE switches.deleted_at IS NULL""",
|
||||||
(s["hostname"], s["ip"], cred_id),
|
(s["hostname"], s["ip"], s.get("ssh_port"), cred_id),
|
||||||
)
|
)
|
||||||
n += 1
|
n += 1
|
||||||
return n
|
return n
|
||||||
@@ -5380,10 +5659,11 @@ def _import_credentials(conn, items):
|
|||||||
n = 0
|
n = 0
|
||||||
for c in items:
|
for c in items:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)
|
"""INSERT INTO credentials (name, username, password, category) VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password
|
ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password,
|
||||||
|
category=excluded.category
|
||||||
WHERE credentials.deleted_at IS NULL""",
|
WHERE credentials.deleted_at IS NULL""",
|
||||||
(c["name"], c["username"], encrypt_password(c["password"])),
|
(c["name"], c["username"], encrypt_password(c["password"]), c.get("category", "switch")),
|
||||||
)
|
)
|
||||||
n += 1
|
n += 1
|
||||||
return n
|
return n
|
||||||
@@ -5436,7 +5716,10 @@ def _import_groups(conn, items):
|
|||||||
if existing:
|
if existing:
|
||||||
group_id = existing["id"]
|
group_id = existing["id"]
|
||||||
else:
|
else:
|
||||||
cur = conn.execute("INSERT INTO groups (name, is_default, is_system) VALUES (?, 0, 0)", (g["name"],))
|
cur = conn.execute(
|
||||||
|
"INSERT INTO groups (name, is_default, is_system) VALUES (?, ?, 0)",
|
||||||
|
(g["name"], g.get("is_default", 0)),
|
||||||
|
)
|
||||||
group_id = cur.lastrowid
|
group_id = cur.lastrowid
|
||||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
||||||
valid_perms = [p for p in g.get("permissions", []) if p in ALL_PERMISSION_KEYS]
|
valid_perms = [p for p in g.get("permissions", []) if p in ALL_PERMISSION_KEYS]
|
||||||
@@ -5469,10 +5752,27 @@ def _import_ldap(conn, data):
|
|||||||
)
|
)
|
||||||
n = 0
|
n = 0
|
||||||
for m in data.get("group_mappings", []):
|
for m in data.get("group_mappings", []):
|
||||||
|
app_group_name = m.get("app_group_name")
|
||||||
|
if app_group_name == "Admin":
|
||||||
|
app_group_id = "admin"
|
||||||
|
else:
|
||||||
|
grow = conn.execute(
|
||||||
|
"SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (app_group_name,)
|
||||||
|
).fetchone()
|
||||||
|
if not grow:
|
||||||
|
continue # Rechtegruppe existiert auf dem Zielsystem (noch) nicht -- Zuordnung überspringen statt kaputt anzulegen
|
||||||
|
app_group_id = grow["id"]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id) VALUES (?, ?, ?) "
|
"INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id) VALUES (?, ?, ?) "
|
||||||
"ON CONFLICT(ad_group_dn) DO UPDATE SET ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id",
|
"ON CONFLICT(ad_group_dn) DO UPDATE SET ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id",
|
||||||
(m["ad_group_dn"], m["ad_group_name"], m["app_group_id"]),
|
(m["ad_group_dn"], m["ad_group_name"], app_group_id),
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
for m in data.get("fileshare_mappings", []):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ldap_fileshare_mappings (ad_group_dn, ad_group_name, share_label, share_unc) VALUES (?, ?, ?, ?) "
|
||||||
|
"ON CONFLICT(ad_group_dn, share_unc) DO UPDATE SET ad_group_name=excluded.ad_group_name, share_label=excluded.share_label",
|
||||||
|
(m["ad_group_dn"], m["ad_group_name"], m["share_label"], m["share_unc"]),
|
||||||
)
|
)
|
||||||
n += 1
|
n += 1
|
||||||
return n
|
return n
|
||||||
@@ -5525,6 +5825,19 @@ def _import_dhcp(conn, data):
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def _import_nginx(conn, data):
|
||||||
|
n = 0
|
||||||
|
for key, value in data.get("settings", {}).items():
|
||||||
|
if key in NGINX_SETTING_KEYS:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||||
|
(key, value),
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
def _import_logs(conn, items):
|
def _import_logs(conn, items):
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
|
||||||
@@ -5536,7 +5849,7 @@ def _import_logs(conn, items):
|
|||||||
IMPORT_APPLIERS = {
|
IMPORT_APPLIERS = {
|
||||||
"devices": _import_devices, "switches": _import_switches, "credentials": _import_credentials,
|
"devices": _import_devices, "switches": _import_switches, "credentials": _import_credentials,
|
||||||
"users": _import_users, "groups": _import_groups, "ldap": _import_ldap, "dhcp": _import_dhcp,
|
"users": _import_users, "groups": _import_groups, "ldap": _import_ldap, "dhcp": _import_dhcp,
|
||||||
"logs": _import_logs,
|
"nginx": _import_nginx, "logs": _import_logs,
|
||||||
}
|
}
|
||||||
|
|
||||||
_pending_imports = {}
|
_pending_imports = {}
|
||||||
@@ -5582,6 +5895,11 @@ def export_data():
|
|||||||
flash("Keine Daten in den ausgewählten Kategorien vorhanden — nichts exportiert.", "danger")
|
flash("Keine Daten in den ausgewählten Kategorien vorhanden — nichts exportiert.", "danger")
|
||||||
return redirect(url_for("settings_import_export"))
|
return redirect(url_for("settings_import_export"))
|
||||||
|
|
||||||
|
if "credentials" in payload:
|
||||||
|
known_hosts_content = _read_known_hosts_content()
|
||||||
|
if known_hosts_content:
|
||||||
|
payload["known_hosts"] = known_hosts_content
|
||||||
|
|
||||||
salt = secrets.token_bytes(16)
|
salt = secrets.token_bytes(16)
|
||||||
export_fernet = _derive_export_fernet(passphrase, salt)
|
export_fernet = _derive_export_fernet(passphrase, salt)
|
||||||
encrypted_payload = export_fernet.encrypt(json.dumps(payload).encode("utf-8"))
|
encrypted_payload = export_fernet.encrypt(json.dumps(payload).encode("utf-8"))
|
||||||
@@ -5594,8 +5912,10 @@ def export_data():
|
|||||||
"payload": encrypted_payload.decode("utf-8"),
|
"payload": encrypted_payload.decode("utf-8"),
|
||||||
}
|
}
|
||||||
|
|
||||||
summary = ", ".join(f"{EXPORT_SECTION_LABELS[k]}: {counts[k]}" for k in payload)
|
summary = ", ".join(f"{EXPORT_SECTION_LABELS[k]}: {counts[k]}" for k in payload if k in EXPORT_SECTION_LABELS)
|
||||||
log_action("data.export", ", ".join(EXPORT_SECTION_LABELS[k] for k in payload), summary)
|
if "known_hosts" in payload:
|
||||||
|
summary += " (inkl. known_hosts)"
|
||||||
|
log_action("data.export", ", ".join(EXPORT_SECTION_LABELS[k] for k in payload if k in EXPORT_SECTION_LABELS), summary)
|
||||||
|
|
||||||
filename = f"tesm_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
filename = f"tesm_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||||
response = jsonify(envelope)
|
response = jsonify(envelope)
|
||||||
@@ -5694,6 +6014,9 @@ def import_apply():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
summary = ", ".join(f"{EXPORT_SECTION_LABELS.get(k, k)}: {results[k]}" for k in selected)
|
summary = ", ".join(f"{EXPORT_SECTION_LABELS.get(k, k)}: {results[k]}" for k in selected)
|
||||||
|
if "credentials" in selected and payload.get("known_hosts"):
|
||||||
|
added = _merge_known_hosts_content(payload["known_hosts"])
|
||||||
|
summary += f", known_hosts: {added} neue Zeile(n)"
|
||||||
log_action("data.import", ", ".join(EXPORT_SECTION_LABELS.get(k, k) for k in selected), summary)
|
log_action("data.import", ", ".join(EXPORT_SECTION_LABELS.get(k, k) for k in selected), summary)
|
||||||
flash(f"Import abgeschlossen: {summary}.", "success")
|
flash(f"Import abgeschlossen: {summary}.", "success")
|
||||||
return redirect(url_for("settings_import_export"))
|
return redirect(url_for("settings_import_export"))
|
||||||
@@ -7366,6 +7689,8 @@ def logs_history():
|
|||||||
return render_template(
|
return render_template(
|
||||||
"logs_history.html", files=files, selected_name=selected_name, selected_file=selected_file,
|
"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,
|
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 +7712,72 @@ def logs_history_raw():
|
|||||||
return response
|
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")
|
@app.route("/logs")
|
||||||
@login_required
|
@login_required
|
||||||
def logs():
|
def logs():
|
||||||
@@ -7435,7 +7826,7 @@ def get_kea_log():
|
|||||||
@login_required
|
@login_required
|
||||||
def kea_log():
|
def kea_log():
|
||||||
"""Eigene Log-Seite für den Kea-DHCP-Dienst, konsistent mit Live-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
|
eigene Logging-Ausgabe explizit in TESM_KEA_LOG_PATH statt in den
|
||||||
distributionsabhängigen Default (meist Syslog), damit sie sich hier
|
distributionsabhängigen Default (meist Syslog), damit sie sich hier
|
||||||
genauso wie die anderen Logs einsehen und per logrotate rotieren
|
genauso wie die anderen Logs einsehen und per logrotate rotieren
|
||||||
@@ -7454,25 +7845,84 @@ def kea_log():
|
|||||||
return render_template("logs_kea.html", log_content=log_content)
|
return render_template("logs_kea.html", log_content=log_content)
|
||||||
|
|
||||||
|
|
||||||
|
AUDIT_LOG_PAGE_SIZE = 300
|
||||||
|
|
||||||
|
|
||||||
@app.route("/logs/aenderungen")
|
@app.route("/logs/aenderungen")
|
||||||
@login_required
|
@login_required
|
||||||
def activity_log():
|
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."""
|
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:
|
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"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
entries = conn.execute(
|
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()
|
).fetchall()
|
||||||
avatars = {
|
# Gesamtzahlen je Kategorie (nicht nur der geladenen Zeilen) für die
|
||||||
row["username"]: row["avatar_filename"]
|
# "X / Y geladen"-Anzeige in den Statistik-Kacheln -- eine einzige
|
||||||
for row in conn.execute("SELECT username, avatar_filename FROM users").fetchall()
|
# 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()
|
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; }
|
.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 {
|
.section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -498,6 +510,7 @@ button { font-family: inherit; }
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.pill svg { width: 12px; height: 12px; }
|
.pill svg { width: 12px; height: 12px; }
|
||||||
.pill::before {
|
.pill::before {
|
||||||
@@ -955,6 +968,48 @@ select {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
animation: modal-in 0.16s ease;
|
animation: modal-in 0.16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Hinweis-Icons (Hover-Tooltip + Klick-Modal), siehe _hint_icon.html
|
||||||
|
und initHintIcons() in app.js ---- */
|
||||||
|
.hint-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--text-faint);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: help;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.hint-icon:hover, .hint-icon:focus-visible { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
#hint-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
pointer-events: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@keyframes modal-in {
|
@keyframes modal-in {
|
||||||
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
@@ -1151,6 +1206,31 @@ select {
|
|||||||
}
|
}
|
||||||
[data-theme="light"] .raw-log-content { background: #0e1116; }
|
[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)
|
Permission checklists (Gruppen)
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|||||||
+120
-2
@@ -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;
|
if (!url) return;
|
||||||
ensureRawLogModal();
|
ensureRawLogModal();
|
||||||
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
|
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
|
||||||
const content = document.getElementById("raw-log-modal-content");
|
const content = document.getElementById("raw-log-modal-content");
|
||||||
const meta = document.getElementById("raw-log-modal-meta");
|
const meta = document.getElementById("raw-log-modal-meta");
|
||||||
|
content.classList.toggle("with-line-numbers", !!withLineNumbers);
|
||||||
content.textContent = "Lade …";
|
content.textContent = "Lade …";
|
||||||
meta.textContent = "";
|
meta.textContent = "";
|
||||||
openModal("raw-log-modal");
|
openModal("raw-log-modal");
|
||||||
@@ -260,10 +278,109 @@
|
|||||||
if (logName) meta.textContent = logName;
|
if (logName) meta.textContent = logName;
|
||||||
return r.text();
|
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."; });
|
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ---------------- Hinweis-Icons (Hover-Tooltip + Klick-Modal) ---------------- */
|
||||||
|
/* Zentraler Mechanismus für ALLE "i"-Icons (siehe _hint_icon.html) app-weit
|
||||||
|
-- ein einziges delegiertes Set von Listenern statt pro Icon eigener
|
||||||
|
Handler, damit auch nachträglich per JS eingefügte Icons (z.B. in
|
||||||
|
dynamisch nachgeladenen Tabellenzeilen) ohne weiteres Zutun funktionieren.
|
||||||
|
Hover zeigt den Text als Tooltip neben dem Mauszeiger -- aber NUR auf
|
||||||
|
Geräten, die tatsächlich sinnvoll hovern können (matchMedia-Check bei
|
||||||
|
JEDEM mouseenter neu ausgewertet, nicht einmalig beim Laden gecacht,
|
||||||
|
damit z.B. ein Convertible/Tablet beim Umschalten Maus/Touch korrekt
|
||||||
|
reagiert). Auf reinen Touch-Geräten bleibt so nur Tap -> Modal übrig,
|
||||||
|
da dort ohnehin kein echtes mouseenter/mousemove-Hover stattfindet. */
|
||||||
|
function ensureHintTooltip() {
|
||||||
|
if (document.getElementById("hint-tooltip")) return;
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.id = "hint-tooltip";
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureHintModal() {
|
||||||
|
if (document.getElementById("hint-modal")) return;
|
||||||
|
const html = `
|
||||||
|
<div class="modal-overlay" id="hint-modal">
|
||||||
|
<div class="modal" style="max-width:440px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="hint-modal-title">Hinweis</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="hint-modal-body" class="text-dim" style="margin:0; line-height:1.6;"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.insertAdjacentHTML("beforeend", html);
|
||||||
|
document.querySelectorAll("#hint-modal [data-close-modal]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => closeModal(btn));
|
||||||
|
});
|
||||||
|
document.getElementById("hint-modal").addEventListener("click", (e) => {
|
||||||
|
if (e.target.id === "hint-modal") closeModal(e.target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function canHover() {
|
||||||
|
return window.matchMedia && window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initHintIcons() {
|
||||||
|
if (!document.querySelector(".hint-icon")) return;
|
||||||
|
ensureHintTooltip();
|
||||||
|
const tooltip = document.getElementById("hint-tooltip");
|
||||||
|
|
||||||
|
function positionTooltip(e) {
|
||||||
|
const offset = 14;
|
||||||
|
const rect = tooltip.getBoundingClientRect();
|
||||||
|
let x = e.clientX + offset;
|
||||||
|
let y = e.clientY + offset;
|
||||||
|
if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - offset;
|
||||||
|
if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offset;
|
||||||
|
tooltip.style.left = Math.max(8, x) + "px";
|
||||||
|
tooltip.style.top = Math.max(8, y) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
// mouseenter/mouseleave bubbeln nicht -- Delegation via Capture-Phase
|
||||||
|
// auf document funktioniert dafür trotzdem zuverlässig.
|
||||||
|
document.addEventListener("mouseenter", function (e) {
|
||||||
|
const icon = e.target.closest && e.target.closest(".hint-icon");
|
||||||
|
if (!icon || !canHover()) return;
|
||||||
|
tooltip.textContent = icon.dataset.hint || "";
|
||||||
|
tooltip.style.display = "block";
|
||||||
|
positionTooltip(e);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener("mouseleave", function (e) {
|
||||||
|
const icon = e.target.closest && e.target.closest(".hint-icon");
|
||||||
|
if (!icon) return;
|
||||||
|
tooltip.style.display = "none";
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", function (e) {
|
||||||
|
if (tooltip.style.display === "block") positionTooltip(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
const icon = e.target.closest && e.target.closest(".hint-icon");
|
||||||
|
if (!icon) return;
|
||||||
|
e.preventDefault();
|
||||||
|
tooltip.style.display = "none";
|
||||||
|
ensureHintModal();
|
||||||
|
document.getElementById("hint-modal-title").textContent = icon.dataset.hintTitle || "Hinweis";
|
||||||
|
document.getElementById("hint-modal-body").textContent = icon.dataset.hint || "";
|
||||||
|
openModal("hint-modal");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
|
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
|
||||||
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
|
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
|
||||||
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
|
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
|
||||||
@@ -607,6 +724,7 @@
|
|||||||
initLeaseCountdowns();
|
initLeaseCountdowns();
|
||||||
initUnsavedChangesGuard();
|
initUnsavedChangesGuard();
|
||||||
initSortableTables();
|
initSortableTables();
|
||||||
|
initHintIcons();
|
||||||
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{#
|
||||||
|
Gemeinsames "i"-Hinweis-Icon: Hover zeigt den Text als Tooltip neben dem
|
||||||
|
Mauszeiger, Klick/Tap öffnet dasselbe (wiederverwendete) Modal mit dem
|
||||||
|
vollen Text -- Mechanik lebt zentral in app.js (initHintIcons), hier wird
|
||||||
|
nur das Icon mit seinen Daten-Attributen erzeugt. title = Kontext für die
|
||||||
|
Modal-Überschrift (z.B. das zugehörige Feld-Label), text = der eigentliche
|
||||||
|
Erklärungstext.
|
||||||
|
#}
|
||||||
|
{% macro hint_icon(text, title='Hinweis') %}<button type="button" class="hint-icon" data-hint="{{ text }}" data-hint-title="{{ title }}" aria-label="Hinweis: {{ title }}">i</button>{% endmacro %}
|
||||||
@@ -1,86 +1,224 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% set active_page = "logs" %}
|
{% 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 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 %}
|
{% block content %}
|
||||||
|
{% import "_audit_log_macros.html" as m %}
|
||||||
|
|
||||||
{% set action_labels = {
|
{% set ns = namespace(create=0, delete=0, edit=0) %}
|
||||||
"settings.update": "Einstellung geändert",
|
{% for e in entries %}
|
||||||
"device.create": "Gerät angelegt",
|
{% set cat = m.category_of(e['action'].split('.')[-1])|trim %}
|
||||||
"device.edit": "Gerät bearbeitet",
|
{% if cat == "create" %}{% set ns.create = ns.create + 1 %}
|
||||||
"device.delete": "Gerät gelöscht",
|
{% elif cat == "delete" %}{% set ns.delete = ns.delete + 1 %}
|
||||||
"device.activate": "Gerät aktiviert",
|
{% else %}{% set ns.edit = ns.edit + 1 %}{% endif %}
|
||||||
"device.deactivate": "Gerät deaktiviert",
|
{% endfor %}
|
||||||
"switch.create": "Switch angelegt",
|
|
||||||
"switch.edit": "Switch bearbeitet",
|
{% macro fmt_count(loaded, total) %}{% if loaded < total %}{{ loaded }} / {{ total }}{% else %}{{ loaded }}{% endif %}{% endmacro %}
|
||||||
"switch.delete": "Switch gelöscht",
|
|
||||||
"credential.create": "Zugangsdaten angelegt",
|
<div class="stat-row" style="margin-bottom:18px;">
|
||||||
"credential.edit": "Zugangsdaten bearbeitet",
|
<div class="stat-card" data-category-filter="">
|
||||||
"credential.delete": "Zugangsdaten gelöscht",
|
<div class="stat-label">Alle</div>
|
||||||
"user.create": "Benutzer angelegt",
|
<div class="stat-value" id="statAll">{{ fmt_count(entries|length, total_count) }}</div>
|
||||||
"user.edit": "Benutzer bearbeitet",
|
</div>
|
||||||
"user.delete": "Benutzer gelöscht",
|
<div class="stat-card" data-category-filter="create">
|
||||||
"user.assign_group": "Gruppe zugewiesen",
|
<div class="stat-label">Hinzufügen</div>
|
||||||
"group.create": "Gruppe angelegt",
|
<div class="stat-value" id="statCreate" style="color:var(--success);">{{ fmt_count(ns.create, total_create) }}</div>
|
||||||
"group.edit": "Gruppe bearbeitet",
|
</div>
|
||||||
"group.delete": "Gruppe gelöscht",
|
<div class="stat-card" data-category-filter="edit">
|
||||||
"group.assign_admins": "Admin-Zuweisung geändert",
|
<div class="stat-label">Änderungen</div>
|
||||||
"profile.update": "Profil aktualisiert",
|
<div class="stat-value" id="statEdit" style="color:var(--accent-strong);">{{ fmt_count(ns.edit, total_edit) }}</div>
|
||||||
"profile.password": "Passwort geändert",
|
</div>
|
||||||
"check.run_now": "Prüfung manuell gestartet",
|
<div class="stat-card" data-category-filter="delete">
|
||||||
"fileshare.upload": "Datei(en) hochgeladen",
|
<div class="stat-label">Löschungen</div>
|
||||||
"fileshare.mkdir": "Ordner angelegt",
|
<div class="stat-value" id="statDelete" style="color:var(--danger);">{{ fmt_count(ns.delete, total_delete) }}</div>
|
||||||
"fileshare.delete": "Datei/Ordner gelöscht",
|
</div>
|
||||||
"fileshare.rename": "Datei/Ordner umbenannt",
|
</div>
|
||||||
"fileshare.download": "Als ZIP heruntergeladen",
|
|
||||||
} %}
|
|
||||||
{% 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 action_kind_class = {
|
|
||||||
"create": "action-pill--create",
|
|
||||||
"upload": "action-pill--create",
|
|
||||||
"mkdir": "action-pill--create",
|
|
||||||
"delete": "action-pill--delete",
|
|
||||||
} %}
|
|
||||||
|
|
||||||
<div class="table-wrap">
|
<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;">
|
<div style="overflow-x:auto;">
|
||||||
<table class="data-table">
|
<table class="data-table" id="auditTable" data-sortable>
|
||||||
<thead><tr><th style="width:1%;"></th><th>Zeitpunkt</th><th>Benutzer</th><th>Aktion</th><th>Ziel</th><th>Details</th></tr></thead>
|
<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>
|
<tbody>
|
||||||
{% for e in entries %}
|
{% for e in entries %}{{ m.audit_row(e) }}{% else %}
|
||||||
{% set kind = e['action'].split('.')[-1] %}
|
<tr class="empty-row"><td colspan="5">Noch keine Änderungen protokolliert.</td></tr>
|
||||||
<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 {{ action_kind_class.get(kind, 'action-pill--edit') }}">
|
|
||||||
<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>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -95,13 +96,12 @@
|
|||||||
<div class="field"><label>Username</label>
|
<div class="field"><label>Username</label>
|
||||||
<input type="text" name="username" required placeholder="z.B. admin">
|
<input type="text" name="username" required placeholder="z.B. admin">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Kategorie</label>
|
<div class="field"><label>Kategorie {{ hi.hint_icon("Bestimmt u.a., ob diese Zugangsdaten unter „Wartung“ für Bulk-Updates nutzbar sind.", "Kategorie") }}</label>
|
||||||
<select name="category">
|
<select name="category">
|
||||||
{% for key, label in categories %}
|
{% for key, label in categories %}
|
||||||
<option value="{{ key }}">{{ label }}</option>
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="hint">Bestimmt u.a., ob diese Zugangsdaten unter „Wartung“ für Bulk-Updates nutzbar sind.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Passwort</label>
|
<div class="field"><label>Passwort</label>
|
||||||
<input type="password" id="password_add" name="password" required>
|
<input type="password" id="password_add" name="password" required>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -125,9 +126,8 @@
|
|||||||
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
|
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
|
||||||
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Switchport</label>
|
<div class="field"><label>Switchport {{ hi.hint_icon("Physische Port-Nummer AM SWITCH (z.B. Port 3 von 48), an dem das Gerät eingesteckt ist — für den PoE-Neustart. Kein Netzwerk-/TCP-Port, keine SSH-Anmeldung.", "Switchport") }}</label>
|
||||||
<input type="text" name="port" placeholder="z.B. 3">
|
<input type="text" name="port" placeholder="z.B. 3">
|
||||||
<div class="field-hint">Physische Port-Nummer AM SWITCH (z.B. Port 3 von 48), an dem das Gerät eingesteckt ist — für den PoE-Neustart. Kein Netzwerk-/TCP-Port, keine SSH-Anmeldung.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Switch (optional)</label>
|
<div class="field"><label>Switch (optional)</label>
|
||||||
<select name="switch_hostname">
|
<select name="switch_hostname">
|
||||||
@@ -136,9 +136,8 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
|
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
|
||||||
<div class="field"><label>SSH-Port</label>
|
<div class="field"><label>SSH-Port {{ hi.hint_icon("TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.", "SSH-Port") }}</label>
|
||||||
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
||||||
<div class="field-hint">TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Zugangsdaten</label>
|
<label>Zugangsdaten</label>
|
||||||
@@ -201,14 +200,12 @@
|
|||||||
<input type="text" name="mac" id="edit_mac" required>
|
<input type="text" name="mac" id="edit_mac" required>
|
||||||
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Switchport</label>
|
<div class="field"><label>Switchport {{ hi.hint_icon("Portnummer am zugeordneten Switch, für den PoE-Neustart. Hat nichts mit dem SSH-Port unten zu tun.", "Switchport") }}</label>
|
||||||
<input type="text" name="port" id="edit_port">
|
<input type="text" name="port" id="edit_port">
|
||||||
<div class="field-hint">Portnummer am zugeordneten Switch, für den PoE-Neustart. Hat nichts mit dem SSH-Port unten zu tun.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
|
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
|
||||||
<div class="field"><label>SSH-Port</label>
|
<div class="field"><label>SSH-Port {{ hi.hint_icon("TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.", "SSH-Port") }}</label>
|
||||||
<input type="number" name="ssh_port" id="edit_ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
<input type="number" name="ssh_port" id="edit_ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
||||||
<div class="field-hint">TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Zugangsdaten</label>
|
<label>Zugangsdaten</label>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
{% macro render_tree_node(share, path, name, expanded_map, active_path) %}
|
{% macro render_tree_node(share, path, name, expanded_map, active_path) %}
|
||||||
<li class="tree-node" data-share="{{ share }}" data-path="{{ path }}">
|
<li class="tree-node" data-share="{{ share }}" data-path="{{ path }}">
|
||||||
@@ -161,10 +162,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Datei(en)</label>
|
<label>Datei(en) {{ hi.hint_icon("Insgesamt maximal 15 MB pro Upload-Vorgang. Mehrfachauswahl möglich (auch mehrmals nacheinander — bereits hinzugefügte Dateien bleiben dabei erhalten).", "Datei(en)") }}</label>
|
||||||
<input type="file" name="file" id="uploadFileInput" multiple required>
|
<input type="file" name="file" id="uploadFileInput" multiple required>
|
||||||
<div id="uploadFileList" class="upload-file-list"></div>
|
<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>
|
||||||
<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 class="field-hint">Wird in „{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}“ hochgeladen. Eine bereits vorhandene Datei gleichen Namens wird überschrieben.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
{% macro permission_table(group, group_key, checked_keys, readonly) %}
|
{% macro permission_table(group, group_key, checked_keys, readonly) %}
|
||||||
{% set row_types = group_row_types[group_key] %}
|
{% set row_types = group_row_types[group_key] %}
|
||||||
@@ -69,11 +70,7 @@
|
|||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Gruppen</h2>
|
<h2 style="font-size:16px;">Gruppen {{ hi.hint_icon("Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich. „Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.", "Gruppen") }}</h2>
|
||||||
<div class="hint">
|
|
||||||
Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich.
|
|
||||||
„Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has_permission('groups.create') %}
|
{% if current_user.has_permission('groups.create') %}
|
||||||
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
|
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
|
||||||
@@ -317,9 +314,8 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="hidden" name="add_group" value="1">
|
<input type="hidden" name="add_group" value="1">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Name</label>
|
<label>Name {{ hi.hint_icon("Mitglieder werden danach über die Gruppentabelle zugeordnet.", "Name") }}</label>
|
||||||
<input type="text" name="name" required placeholder="z.B. Facility-Team">
|
<input type="text" name="name" required placeholder="z.B. Facility-Team">
|
||||||
<div class="field-hint">Mitglieder werden danach über die Gruppentabelle zugeordnet.</div>
|
|
||||||
</div>
|
</div>
|
||||||
{{ permission_tree([], false) }}
|
{{ permission_tree([], false) }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ selected_file.range_label if selected_file else "kein Log" }}</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ selected_file.range_label if selected_file else "kein Log" }}</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Log-Verlauf</h2>
|
<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">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>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="{{ url_for('logs') }}" class="btn btn-secondary">
|
<a href="{{ url_for('logs') }}" class="btn btn-secondary">
|
||||||
@@ -16,7 +18,7 @@
|
|||||||
Zurück zur Live-Ansicht
|
Zurück zur Live-Ansicht
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="btn btn-secondary"
|
<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 %}>
|
{% 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>
|
<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)
|
Komplettes Log (RAW)
|
||||||
@@ -50,6 +52,54 @@
|
|||||||
<pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre>
|
<pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="section-head" style="margin-top:28px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Archivierte Auditlog-Tage {{ hi.hint_icon("Ä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.", "Archivierte Auditlog-Tage") }}</h2>
|
||||||
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|||||||
@@ -4,15 +4,11 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Wartung (SSH)</h2>
|
<h2 style="font-size:16px;">Wartung (SSH) {{ hi.hint_icon("Bulk-Update (apt update & upgrade -y) und Neustart per SSH direkt auf dem Gerät — unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.", "Wartung (SSH)") }}</h2>
|
||||||
<div class="hint">
|
|
||||||
Bulk-Update (<code>apt update & upgrade -y</code>) und Neustart per SSH direkt auf dem Gerät —
|
|
||||||
unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten
|
|
||||||
SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,7 +83,9 @@
|
|||||||
|
|
||||||
{% if can_run %}
|
{% if can_run %}
|
||||||
<div class="section-head" style="margin-top:16px;">
|
<div class="section-head" style="margin-top:16px;">
|
||||||
<div class="hint">Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet) oder neu starten.</div>
|
<div style="display:flex; align-items:center; gap:6px; font-size:12.5px; color:var(--text-faint);">
|
||||||
|
Bulk-Aktionen {{ hi.hint_icon("Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet) oder neu starten.", "Bulk-Aktionen") }}
|
||||||
|
</div>
|
||||||
<div style="display:flex; gap:8px;">
|
<div style="display:flex; gap:8px;">
|
||||||
<button type="button" class="btn btn-secondary" onclick="bulkReboot()">
|
<button type="button" class="btn btn-secondary" onclick="bulkReboot()">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">Prüfintervall und Netzwerkkonfiguration dieses Hosts</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Prüfintervall und Netzwerkkonfiguration dieses Hosts</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
@@ -27,13 +28,12 @@
|
|||||||
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="timezone">Zeitzone</label>
|
<label for="timezone">Zeitzone {{ hi.hint_icon("Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.", "Zeitzone") }}</label>
|
||||||
<select name="timezone" id="timezone" required>
|
<select name="timezone" id="timezone" required>
|
||||||
{% for tz in timezones %}
|
{% for tz in timezones %}
|
||||||
<option value="{{ tz }}" {% if tz == current_timezone %}selected{% endif %}>{{ tz }}</option>
|
<option value="{{ tz }}" {% if tz == current_timezone %}selected{% endif %}>{{ tz }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="field-hint">Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block">
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
<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>
|
<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>
|
||||||
@@ -43,9 +43,8 @@
|
|||||||
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="interval">Prüfintervall (Minuten)</label>
|
<label for="interval">Prüfintervall (Minuten) {{ hi.hint_icon("Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (tesm-check.service) wird nach dem Speichern automatisch neu gestartet.", "Prüfintervall (Minuten)") }}</label>
|
||||||
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
|
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
|
||||||
<div class="field-hint">Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (tesm-check.service) wird nach dem Speichern automatisch neu gestartet.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block">
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
<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>
|
<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>
|
||||||
@@ -118,13 +117,12 @@
|
|||||||
{% elif net_backend != 'unknown' and current_user.has_permission('settings_system.edit') %}
|
{% elif net_backend != 'unknown' and current_user.has_permission('settings_system.edit') %}
|
||||||
<form method="post" data-confirm="Netzwerkkonfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ net_revert_seconds }} Sekunden wiederhergestellt.">
|
<form method="post" data-confirm="Netzwerkkonfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ net_revert_seconds }} Sekunden wiederhergestellt.">
|
||||||
<input type="hidden" name="apply_network" value="1">
|
<input type="hidden" name="apply_network" value="1">
|
||||||
<div class="field"><label>Interface</label>
|
<div class="field"><label>Interface {{ hi.hint_icon("Jedes Interface hat seine eigene Konfiguration — ein Wechsel hier lädt unten dessen tatsächlichen Ist-Zustand, ändert aber noch nichts.", "Interface") }}</label>
|
||||||
<select name="net_interface" id="netInterfaceSelect" onchange="loadNetworkState(this.value)">
|
<select name="net_interface" id="netInterfaceSelect" onchange="loadNetworkState(this.value)">
|
||||||
{% for iface in net_interfaces %}
|
{% for iface in net_interfaces %}
|
||||||
<option value="{{ iface }}" {% if iface == net_interface %}selected{% endif %}>{{ iface }}</option>
|
<option value="{{ iface }}" {% if iface == net_interface %}selected{% endif %}>{{ iface }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="field-hint">Jedes Interface hat seine eigene Konfiguration — ein Wechsel hier lädt unten dessen tatsächlichen Ist-Zustand, ändert aber noch nichts.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Modus</label>
|
<div class="field"><label>Modus</label>
|
||||||
<select name="net_mode" id="netModeSelect" onchange="document.getElementById('netStaticFields').classList.toggle('hidden', this.value !== 'static')">
|
<select name="net_mode" id="netModeSelect" onchange="document.getElementById('netStaticFields').classList.toggle('hidden', this.value !== 'static')">
|
||||||
@@ -143,9 +141,8 @@
|
|||||||
<input type="text" name="net_gateway" id="netGatewayInput" value="{{ net_state.gateway if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.1">
|
<input type="text" name="net_gateway" id="netGatewayInput" value="{{ net_state.gateway if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.1">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>DNS-Server</label>
|
<div class="field"><label>DNS-Server {{ hi.hint_icon("Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.", "DNS-Server") }}</label>
|
||||||
<input type="text" name="net_dns" id="netDnsInput" value="{{ net_state.dns|join(', ') if net_state and net_state.dns else '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
<input type="text" name="net_dns" id="netDnsInput" value="{{ net_state.dns|join(', ') if net_state and net_state.dns else '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
||||||
<div class="field-hint">Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block">
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
<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>
|
<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>
|
||||||
@@ -184,9 +181,8 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="log_rotation_keep">Aufbewahrung (Anzahl Rotationen)</label>
|
<label for="log_rotation_keep">Aufbewahrung (Anzahl Rotationen) {{ hi.hint_icon("Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).", "Aufbewahrung (Anzahl Rotationen)") }}</label>
|
||||||
<input type="number" name="log_rotation_keep" id="log_rotation_keep" value="{{ log_rotation_keep }}" min="1" required>
|
<input type="number" name="log_rotation_keep" id="log_rotation_keep" value="{{ log_rotation_keep }}" min="1" required>
|
||||||
<div class="field-hint">Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block">
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
<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>
|
<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>
|
||||||
@@ -221,9 +217,8 @@
|
|||||||
{% if current_user.has_permission('settings_system.edit') %}
|
{% if current_user.has_permission('settings_system.edit') %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="trash_retention_days">Aufbewahrungsdauer (Tage)</label>
|
<label for="trash_retention_days">Aufbewahrungsdauer (Tage) {{ hi.hint_icon("Danach werden Papierkorb-Einträge automatisch unwiderruflich gelöscht (AD/LDAP-Benutzer sind nie im Papierkorb, da sie sich beim nächsten Login automatisch neu anlegen).", "Aufbewahrungsdauer (Tage)") }}</label>
|
||||||
<input type="number" name="trash_retention_days" id="trash_retention_days" value="{{ trash_retention_days }}" min="1" required>
|
<input type="number" name="trash_retention_days" id="trash_retention_days" value="{{ trash_retention_days }}" min="1" required>
|
||||||
<div class="field-hint">Danach werden Papierkorb-Einträge automatisch unwiderruflich gelöscht (AD/LDAP-Benutzer sind nie im Papierkorb, da sie sich beim nächsten Login automatisch neu anlegen).</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" name="save_trash_retention" value="1" class="btn btn-primary btn-block">
|
<button type="submit" name="save_trash_retention" value="1" class="btn btn-primary btn-block">
|
||||||
<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>
|
<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>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
@@ -81,9 +82,8 @@
|
|||||||
<div class="field"><label>Lease-Zeit Maximum (Sek.)</label>
|
<div class="field"><label>Lease-Zeit Maximum (Sek.)</label>
|
||||||
<input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}">
|
<input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei)</label>
|
<div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei) {{ hi.hint_icon("Standardmäßig die aktive Kea-Konfiguration. Läuft der Dienst gerade, schreibt „Speichern“ die Datei neu und startet ihn automatisch neu.", "Ausgabepfad (Kea-Konfigurationsdatei)") }}</label>
|
||||||
<input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono">
|
<input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono">
|
||||||
<div class="field-hint">Standardmäßig die aktive Kea-Konfiguration. Läuft der Dienst gerade, schreibt „Speichern“ die Datei neu und startet ihn automatisch neu.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block">
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
<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>
|
<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>
|
||||||
@@ -98,11 +98,7 @@
|
|||||||
<div class="card card-pad" style="grid-column:1 / -1;">
|
<div class="card card-pad" style="grid-column:1 / -1;">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Subnetze</h2>
|
<h2 style="font-size:16px;">Subnetze {{ hi.hint_icon("Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range, optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.", "Subnetze") }}</h2>
|
||||||
<div class="hint">
|
|
||||||
Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range,
|
|
||||||
optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<button type="button" class="btn btn-primary" data-open-modal="addSubnetModal">
|
<button type="button" class="btn btn-primary" data-open-modal="addSubnetModal">
|
||||||
@@ -175,11 +171,7 @@
|
|||||||
<div class="card card-pad" style="grid-column:1 / -1;">
|
<div class="card card-pad" style="grid-column:1 / -1;">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">DHCP-Options</h2>
|
<h2 style="font-size:16px;">DHCP-Options {{ hi.hint_icon('Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt, eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.', "DHCP-Options") }}</h2>
|
||||||
<div class="hint">
|
|
||||||
Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt,
|
|
||||||
eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
|
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
|
||||||
@@ -217,7 +209,9 @@
|
|||||||
<input type="text" name="value" value="{{ option_values.get(d.id, {}).get('', '') }}" style="max-width:220px;" placeholder="(nicht gesetzt)">
|
<input type="text" name="value" value="{{ option_values.get(d.id, {}).get('', '') }}" style="max-width:220px;" placeholder="(nicht gesetzt)">
|
||||||
<button type="submit" class="btn btn-secondary btn-sm">Speichern</button>
|
<button type="submit" class="btn btn-secondary btn-sm">Speichern</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="field-hint">Leeren Wert speichern, um eine Standard-Option wieder auszublenden.</div>
|
<div class="text-faint" style="font-size:11.5px; display:flex; align-items:center; gap:6px;">
|
||||||
|
Standard-Option zurücksetzen {{ hi.hint_icon("Leeren Wert speichern, um eine Standard-Option wieder auszublenden.", "Standard-Option zurücksetzen") }}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ option_values.get(d.id, {}).get('', '—') }}
|
{{ option_values.get(d.id, {}).get('', '—') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -272,11 +266,7 @@
|
|||||||
<div class="card card-pad" style="grid-column:1 / -1;">
|
<div class="card card-pad" style="grid-column:1 / -1;">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Aktive Leases</h2>
|
<h2 style="font-size:16px;">Aktive Leases {{ hi.hint_icon("Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung, die sich einfach eine freie IP aus dem Pool genommen haben.", "Aktive Leases") }}</h2>
|
||||||
<div class="hint">
|
|
||||||
Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung,
|
|
||||||
die sich einfach eine freie IP aus dem Pool genommen haben.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -497,9 +487,8 @@
|
|||||||
<div class="field"><label>Code</label>
|
<div class="field"><label>Code</label>
|
||||||
<input type="number" name="code" min="1" max="254" required placeholder="z.B. 225">
|
<input type="number" name="code" min="1" max="254" required placeholder="z.B. 225">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Name</label>
|
<div class="field"><label>Name {{ hi.hint_icon("Nur Kleinbuchstaben, Ziffern und Bindestrich, muss mit einem Buchstaben beginnen.", "Name") }}</label>
|
||||||
<input type="text" name="name" required placeholder="z.B. terminal-url" pattern="[a-z][a-z0-9-]*">
|
<input type="text" name="name" required placeholder="z.B. terminal-url" pattern="[a-z][a-z0-9-]*">
|
||||||
<div class="field-hint">Nur Kleinbuchstaben, Ziffern und Bindestrich, muss mit einem Buchstaben beginnen.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Datentyp</label>
|
<div class="field"><label>Datentyp</label>
|
||||||
<select name="type">
|
<select name="type">
|
||||||
@@ -539,13 +528,11 @@
|
|||||||
<div class="field"><label>MAC-Adresse</label>
|
<div class="field"><label>MAC-Adresse</label>
|
||||||
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
|
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>IP-Adresse</label>
|
<div class="field"><label>IP-Adresse {{ hi.hint_icon("Muss in einem der oben konfigurierten Subnetze liegen, sonst wird sie beim Schreiben übersprungen.", "IP-Adresse") }}</label>
|
||||||
<input type="text" name="ip" required placeholder="z.B. 192.168.1.50">
|
<input type="text" name="ip" required placeholder="z.B. 192.168.1.50">
|
||||||
<div class="field-hint">Muss in einem der oben konfigurierten Subnetze liegen, sonst wird sie beim Schreiben übersprungen.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Name</label>
|
<div class="field"><label>Name {{ hi.hint_icon("Wird zum Hostname der Reservierung. DHCP-Optionen (z.B. Hostname erzwingen) lassen sich danach über das Options-Symbol in der Tabelle setzen.", "Name") }}</label>
|
||||||
<input type="text" name="name" required placeholder="z.B. Empfangsdrucker">
|
<input type="text" name="name" required placeholder="z.B. Empfangsdrucker">
|
||||||
<div class="field-hint">Wird zum Hostname der Reservierung. DHCP-Optionen (z.B. Hostname erzwingen) lassen sich danach über das Options-Symbol in der Tabelle setzen.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -575,16 +562,14 @@
|
|||||||
<div class="field"><label>Range Start</label>
|
<div class="field"><label>Range Start</label>
|
||||||
<input type="text" name="range_start" required placeholder="z.B. 192.168.1.100">
|
<input type="text" name="range_start" required placeholder="z.B. 192.168.1.100">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Range Ende</label>
|
<div class="field"><label>Range Ende {{ hi.hint_icon("Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.", "Range Ende") }}</label>
|
||||||
<input type="text" name="range_end" required placeholder="z.B. 192.168.1.200">
|
<input type="text" name="range_end" required placeholder="z.B. 192.168.1.200">
|
||||||
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Gateway (optional)</label>
|
<div class="field"><label>Gateway (optional)</label>
|
||||||
<input type="text" name="gateway" placeholder="Automatisch: erkanntes Gateway des Interfaces">
|
<input type="text" name="gateway" placeholder="Automatisch: erkanntes Gateway des Interfaces">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>DNS-Server (optional)</label>
|
<div class="field"><label>DNS-Server (optional) {{ hi.hint_icon("Kommagetrennt, falls mehrere.", "DNS-Server (optional)") }}</label>
|
||||||
<input type="text" name="dns" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
<input type="text" name="dns" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
||||||
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -617,16 +602,14 @@
|
|||||||
<div class="field"><label>Range Start</label>
|
<div class="field"><label>Range Start</label>
|
||||||
<input type="text" name="range_start" value="{{ s.range_start }}" required>
|
<input type="text" name="range_start" value="{{ s.range_start }}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Range Ende</label>
|
<div class="field"><label>Range Ende {{ hi.hint_icon("Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.", "Range Ende") }}</label>
|
||||||
<input type="text" name="range_end" value="{{ s.range_end }}" required>
|
<input type="text" name="range_end" value="{{ s.range_end }}" required>
|
||||||
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Gateway (optional)</label>
|
<div class="field"><label>Gateway (optional)</label>
|
||||||
<input type="text" name="gateway" value="{{ s.gateway or '' }}" placeholder="Automatisch: erkanntes Gateway des Interfaces">
|
<input type="text" name="gateway" value="{{ s.gateway or '' }}" placeholder="Automatisch: erkanntes Gateway des Interfaces">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>DNS-Server (optional)</label>
|
<div class="field"><label>DNS-Server (optional) {{ hi.hint_icon("Kommagetrennt, falls mehrere.", "DNS-Server (optional)") }}</label>
|
||||||
<input type="text" name="dns" value="{{ s.dns or '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
<input type="text" name="dns" value="{{ s.dns or '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
||||||
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
data-confirm="Ausgewählte Kategorien wirklich importieren? Bestehende Einträge mit gleichem Namen/Hostname/MAC/Benutzernamen werden überschrieben.">
|
data-confirm="Ausgewählte Kategorien wirklich importieren? Bestehende Einträge mit gleichem Namen/Hostname/MAC/Benutzernamen werden überschrieben.">
|
||||||
<input type="hidden" name="import_token" value="{{ import_preview.token }}">
|
<input type="hidden" name="import_token" value="{{ import_preview.token }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Was importieren?</label>
|
<label>Was importieren? {{ hi.hint_icon("AD/LDAP-Benutzerkonten sind hiervon unberührt — sie werden über Active Directory/Windows verwaltet, nicht über diese App, und beim nächsten Login automatisch neu angelegt.", "Was importieren?") }}</label>
|
||||||
<div class="check-list">
|
<div class="check-list">
|
||||||
{% for s in import_preview.sections %}
|
{% for s in import_preview.sections %}
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
@@ -38,10 +39,6 @@
|
|||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="field-hint">
|
|
||||||
AD/LDAP-Benutzerkonten sind hiervon unberührt — sie werden über Active Directory/Windows verwaltet,
|
|
||||||
nicht über diese App, und beim nächsten Login automatisch neu angelegt.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block">
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
<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>
|
<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>
|
||||||
@@ -77,7 +74,7 @@
|
|||||||
{% if current_user.has_permission('settings_importexport.export') %}
|
{% if current_user.has_permission('settings_importexport.export') %}
|
||||||
<form method="post" action="{{ url_for('export_data') }}">
|
<form method="post" action="{{ url_for('export_data') }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Was exportieren?</label>
|
<label>Was exportieren? {{ hi.hint_icon("AD/LDAP-Benutzerkonten werden nie mitexportiert — sie werden über Active Directory/Windows verwaltet (nicht über diese App) und legen sich beim nächsten Login automatisch wieder an. Beim LDAP-Export wird lediglich das Bind-Konto (verschlüsselt) sowie die Gruppenzuordnungen gesichert.", "Was exportieren?") }}</label>
|
||||||
<div class="check-list">
|
<div class="check-list">
|
||||||
{% for key, label in export_sections %}
|
{% for key, label in export_sections %}
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
@@ -89,16 +86,10 @@
|
|||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="field-hint">
|
|
||||||
AD/LDAP-Benutzerkonten werden nie mitexportiert — sie werden über Active Directory/Windows verwaltet
|
|
||||||
(nicht über diese App) und legen sich beim nächsten Login automatisch wieder an. Beim LDAP-Export wird
|
|
||||||
lediglich das Bind-Konto (verschlüsselt) sowie die Gruppenzuordnungen gesichert.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="export_passphrase">Passphrase</label>
|
<label for="export_passphrase">Passphrase {{ hi.hint_icon("Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.", "Passphrase") }}</label>
|
||||||
<input type="password" name="export_passphrase" id="export_passphrase" required>
|
<input type="password" name="export_passphrase" id="export_passphrase" required>
|
||||||
<div class="field-hint">Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-secondary btn-block">
|
<button type="submit" class="btn btn-secondary btn-block">
|
||||||
<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>
|
<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>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
<div class="settings-grid settings-grid--wide">
|
<div class="settings-grid settings-grid--wide">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
@@ -23,18 +24,16 @@
|
|||||||
<span>LDAP-Anmeldung aktivieren</span>
|
<span>LDAP-Anmeldung aktivieren</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Server</label>
|
<div class="field"><label>Server {{ hi.hint_icon("DNS-Name oder IP-Adresse — beides wird genau so gespeichert und beim Verbinden verwendet.", "Server") }}</label>
|
||||||
<input type="text" name="ldap_server" value="{{ ldap.server }}" placeholder="z.B. 192.168.1.1 oder dc01.firma.local">
|
<input type="text" name="ldap_server" value="{{ ldap.server }}" placeholder="z.B. 192.168.1.1 oder dc01.firma.local">
|
||||||
<div class="field-hint">DNS-Name oder IP-Adresse — beides wird genau so gespeichert und beim Verbinden verwendet.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||||
<input type="checkbox" name="ldap_use_ssl" id="ldap_use_ssl" {% if ldap.use_ssl %}checked{% endif %}
|
<input type="checkbox" name="ldap_use_ssl" id="ldap_use_ssl" {% if ldap.use_ssl %}checked{% endif %}
|
||||||
onchange="document.getElementById('ldap_port').value = this.checked ? 636 : 389;">
|
onchange="document.getElementById('ldap_port').value = this.checked ? 636 : 389;">
|
||||||
<span class="track"></span>
|
<span class="track"></span>
|
||||||
<span>LDAPS/TLS verwenden</span>
|
<span>LDAPS/TLS verwenden {{ hi.hint_icon("Ohne LDAPS wird das Passwort unverschlüsselt übertragen — nur für interne Tests geeignet, vor Produktivbetrieb LDAPS auf dem Domain Controller einrichten. Stellt beim Umschalten den Port automatisch auf 636/389 — unten weiterhin manuell änderbar.", "LDAPS/TLS verwenden") }}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="field-hint">Ohne LDAPS wird das Passwort unverschlüsselt übertragen — nur für interne Tests geeignet, vor Produktivbetrieb LDAPS auf dem Domain Controller einrichten. Stellt beim Umschalten den Port automatisch auf 636/389 — unten weiterhin manuell änderbar.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Port</label>
|
<div class="field"><label>Port</label>
|
||||||
<input type="number" name="ldap_port" id="ldap_port" min="1" max="65535" value="{{ ldap.port }}">
|
<input type="number" name="ldap_port" id="ldap_port" min="1" max="65535" value="{{ ldap.port }}">
|
||||||
@@ -43,13 +42,11 @@
|
|||||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||||
<input type="checkbox" name="ldap_tls_skip_verify" {% if ldap.tls_skip_verify %}checked{% endif %}>
|
<input type="checkbox" name="ldap_tls_skip_verify" {% if ldap.tls_skip_verify %}checked{% endif %}>
|
||||||
<span class="track"></span>
|
<span class="track"></span>
|
||||||
<span>Zertifikatsprüfung überspringen (nur LDAPS)</span>
|
<span>Zertifikatsprüfung überspringen (nur LDAPS) {{ hi.hint_icon("Akzeptiert jedes Server-Zertifikat, auch selbstsignierte/nicht vertrauenswürdige — praktisch für interne Tests, schützt dann aber nicht mehr vor einem gefälschten Server. Vor Produktivbetrieb ein echtes, vertrauenswürdiges Zertifikat einrichten und diese Option deaktivieren.", "Zertifikatsprüfung überspringen") }}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="field-hint">Akzeptiert jedes Server-Zertifikat, auch selbstsignierte/nicht vertrauenswürdige — praktisch für interne Tests, schützt dann aber nicht mehr vor einem gefälschten Server. Vor Produktivbetrieb ein echtes, vertrauenswürdiges Zertifikat einrichten und diese Option deaktivieren.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Bind-Konto (Service-Account)</label>
|
<div class="field"><label>Bind-Konto (Service-Account) {{ hi.hint_icon("Ein normales, unprivilegiertes Domänenkonto reicht — es wird nur zum Suchen von Benutzern verwendet, keine Admin-Rechte nötig. Ein neu gespeichertes Konto ersetzt das bisherige vollständig.", "Bind-Konto (Service-Account)") }}</label>
|
||||||
<input type="text" name="ldap_bind_dn" value="{{ ldap.bind_dn }}" placeholder="z.B. ldap@ad.firma.local">
|
<input type="text" name="ldap_bind_dn" value="{{ ldap.bind_dn }}" placeholder="z.B. ldap@ad.firma.local">
|
||||||
<div class="field-hint">Ein normales, unprivilegiertes Domänenkonto reicht — es wird nur zum Suchen von Benutzern verwendet, keine Admin-Rechte nötig. Ein neu gespeichertes Konto ersetzt das bisherige vollständig.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Bind-Passwort</label>
|
<div class="field"><label>Bind-Passwort</label>
|
||||||
<input type="password" name="ldap_bind_password" placeholder="{{ '(unverändert lassen)' if ldap.bind_password_enc else '' }}">
|
<input type="password" name="ldap_bind_password" placeholder="{{ '(unverändert lassen)' if ldap.bind_password_enc else '' }}">
|
||||||
@@ -57,18 +54,16 @@
|
|||||||
<div class="field"><label>Base-DN</label>
|
<div class="field"><label>Base-DN</label>
|
||||||
<input type="text" name="ldap_base_dn" value="{{ ldap.base_dn }}" placeholder="Leer = automatisch ermitteln">
|
<input type="text" name="ldap_base_dn" value="{{ ldap.base_dn }}" placeholder="Leer = automatisch ermitteln">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Attribut für Benutzername</label>
|
<div class="field"><label>Attribut für Benutzername {{ hi.hint_icon("Für Active Directory: sAMAccountName. Für generisches LDAP (z.B. OpenLDAP): meist uid. Anmeldung per userPrincipalName (E-Mail/UPN) funktioniert unabhängig davon immer zusätzlich.", "Attribut für Benutzername") }}</label>
|
||||||
<input type="text" name="ldap_user_filter_attr" value="{{ ldap.filter_attr }}" placeholder="sAMAccountName">
|
<input type="text" name="ldap_user_filter_attr" value="{{ ldap.filter_attr }}" placeholder="sAMAccountName">
|
||||||
<div class="field-hint">Für Active Directory: sAMAccountName. Für generisches LDAP (z.B. OpenLDAP): meist uid. Anmeldung per userPrincipalName (E-Mail/UPN) funktioniert unabhängig davon immer zusätzlich.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Standardgruppe für neue AD-Benutzer</label>
|
<div class="field"><label>Standardgruppe für neue AD-Benutzer {{ hi.hint_icon("Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.", "Standardgruppe für neue AD-Benutzer") }}</label>
|
||||||
<select name="ldap_default_group">
|
<select name="ldap_default_group">
|
||||||
<option value="">Systemstandard ({{ ldap_groups|selectattr('is_default')|map(attribute='name')|first or 'Benutzer' }})</option>
|
<option value="">Systemstandard ({{ ldap_groups|selectattr('is_default')|map(attribute='name')|first or 'Benutzer' }})</option>
|
||||||
{% for g in ldap_groups %}
|
{% for g in ldap_groups %}
|
||||||
<option value="{{ g['id'] }}" {% if ldap.default_group == g['id']|string %}selected{% endif %}>{{ g['name'] }}</option>
|
<option value="{{ g['id'] }}" {% if ldap.default_group == g['id']|string %}selected{% endif %}>{{ g['name'] }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Erforderliche AD-Gruppe für Login (optional)</label>
|
<div class="field"><label>Erforderliche AD-Gruppe für Login (optional)</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -108,12 +103,7 @@
|
|||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">AD-Gruppenzuordnungen</h2>
|
<h2 style="font-size:16px;">AD-Gruppenzuordnungen {{ hi.hint_icon("Ist ein AD-Benutzer (rekursiv, auch über verschachtelte Gruppen) Mitglied einer hier zugeordneten AD-Gruppe, erhält er beim Login zusätzlich die zugeordnete App-Rechtegruppe — additiv, mehrere Zuordnungen können gleichzeitig greifen. Wird keine Zuordnung getroffen, gilt die Standardgruppe oben.", "AD-Gruppenzuordnungen") }}</h2>
|
||||||
<div class="hint">
|
|
||||||
Ist ein AD-Benutzer (rekursiv, auch über verschachtelte Gruppen) Mitglied einer hier zugeordneten
|
|
||||||
AD-Gruppe, erhält er beim Login zusätzlich die zugeordnete App-Rechtegruppe — additiv, mehrere
|
|
||||||
Zuordnungen können gleichzeitig greifen. Wird keine Zuordnung getroffen, gilt die Standardgruppe oben.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<button type="button" class="btn btn-primary" data-open-modal="addLdapMappingModal">
|
<button type="button" class="btn btn-primary" data-open-modal="addLdapMappingModal">
|
||||||
@@ -163,13 +153,7 @@
|
|||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Fileshare-Gruppen</h2>
|
<h2 style="font-size:16px;">Fileshare-Gruppenzuordnung {{ hi.hint_icon("Ist ein AD-Benutzer (rekursiv) Mitglied einer hier zugeordneten AD-Gruppe, wird die zugehörige Freigabe beim Login für ihn gemountet — sofern er zusätzlich das TESM-Recht „Dateifreigaben lesen“ hat (siehe Gruppen → Rechte, Bereich „Dateifreigaben“). Fehlt das Recht, wird nicht gemountet und der Menüpunkt „Dateifreigaben“ erscheint nicht, unabhängig von der AD-Gruppenmitgliedschaft.", "Fileshare-Gruppenzuordnung") }}</h2>
|
||||||
<div class="hint">
|
|
||||||
Ist ein AD-Benutzer (rekursiv) Mitglied einer hier zugeordneten AD-Gruppe, wird die zugehörige
|
|
||||||
Freigabe beim Login für ihn gemountet — sofern er zusätzlich das TESM-Recht „Dateifreigaben lesen“
|
|
||||||
hat (siehe Gruppen → Rechte, Bereich „Dateifreigaben“). Fehlt das Recht, wird nicht gemountet und
|
|
||||||
der Menüpunkt „Dateifreigaben“ erscheint nicht, unabhängig von der AD-Gruppenmitgliedschaft.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<button type="button" class="btn btn-primary" data-open-modal="addFileshareMappingModal">
|
<button type="button" class="btn btn-primary" data-open-modal="addFileshareMappingModal">
|
||||||
@@ -278,13 +262,11 @@
|
|||||||
<input type="hidden" name="fs_ad_group_name" id="fsMappingAdGroupName">
|
<input type="hidden" name="fs_ad_group_name" id="fsMappingAdGroupName">
|
||||||
<div class="field-hint" id="fsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
<div class="field-hint" id="fsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Bezeichnung</label>
|
<div class="field"><label>Bezeichnung {{ hi.hint_icon("Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.", "Bezeichnung") }}</label>
|
||||||
<input type="text" name="fs_share_label" placeholder="z.B. Vertrieb" required>
|
<input type="text" name="fs_share_label" placeholder="z.B. Vertrieb" required>
|
||||||
<div class="field-hint">Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Freigabe-Pfad (UNC)</label>
|
<div class="field"><label>Freigabe-Pfad (UNC) {{ hi.hint_icon("Beide Schreibweisen funktionieren — \\\\server\\freigabe wird automatisch in das von Linux benötigte //server/freigabe umgewandelt.", "Freigabe-Pfad (UNC)") }}</label>
|
||||||
<input type="text" name="fs_share_unc" placeholder="//fileserver/freigabe" required>
|
<input type="text" name="fs_share_unc" placeholder="//fileserver/freigabe" required>
|
||||||
<div class="field-hint">Beide Schreibweisen funktionieren — <code>\\server\freigabe</code> wird automatisch in das von Linux benötigte <code>//server/freigabe</code> umgewandelt.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -349,13 +331,11 @@
|
|||||||
<input type="hidden" name="fs_ad_group_name" id="editFsMappingAdGroupName">
|
<input type="hidden" name="fs_ad_group_name" id="editFsMappingAdGroupName">
|
||||||
<div class="field-hint" id="editFsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
<div class="field-hint" id="editFsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Bezeichnung</label>
|
<div class="field"><label>Bezeichnung {{ hi.hint_icon("Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.", "Bezeichnung") }}</label>
|
||||||
<input type="text" name="fs_share_label" id="editFsMappingLabel" placeholder="z.B. Vertrieb" required>
|
<input type="text" name="fs_share_label" id="editFsMappingLabel" placeholder="z.B. Vertrieb" required>
|
||||||
<div class="field-hint">Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Freigabe-Pfad (UNC)</label>
|
<div class="field"><label>Freigabe-Pfad (UNC) {{ hi.hint_icon("Beide Schreibweisen funktionieren — \\\\server\\freigabe wird automatisch in das von Linux benötigte //server/freigabe umgewandelt.", "Freigabe-Pfad (UNC)") }}</label>
|
||||||
<input type="text" name="fs_share_unc" id="editFsMappingUnc" placeholder="//fileserver/freigabe" required>
|
<input type="text" name="fs_share_unc" id="editFsMappingUnc" placeholder="//fileserver/freigabe" required>
|
||||||
<div class="field-hint">Beide Schreibweisen funktionieren — <code>\\server\freigabe</code> wird automatisch in das von Linux benötigte <code>//server/freigabe</code> umgewandelt.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
<div class="settings-grid settings-grid--wide">
|
<div class="settings-grid settings-grid--wide">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
@@ -38,9 +39,8 @@
|
|||||||
<form method="post"
|
<form method="post"
|
||||||
data-confirm="nginx-Konfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ nginx_revert_seconds }} Sekunden wiederhergestellt.">
|
data-confirm="nginx-Konfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ nginx_revert_seconds }} Sekunden wiederhergestellt.">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="server_name">Domain (server_name)</label>
|
<label for="server_name">Domain (server_name) {{ hi.hint_icon("\"_\" ist nginx' Catch-all (Standard für interne Instanzen ohne eigene Domain) — für Let's Encrypt muss hier die tatsächliche, öffentlich auflösbare Domain stehen.", "Domain (server_name)") }}</label>
|
||||||
<input type="text" name="server_name" id="server_name" value="{{ server_name }}" placeholder="_ (kein bestimmter Hostname) oder z.B. tesm.example.com">
|
<input type="text" name="server_name" id="server_name" value="{{ server_name }}" placeholder="_ (kein bestimmter Hostname) oder z.B. tesm.example.com">
|
||||||
<div class="field-hint">"_" ist nginx' Catch-all (Standard für interne Instanzen ohne eigene Domain) — für Let's Encrypt muss hier die tatsächliche, öffentlich auflösbare Domain stehen.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<div class="field field--half">
|
<div class="field field--half">
|
||||||
@@ -48,14 +48,10 @@
|
|||||||
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
|
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field field--half">
|
<div class="field field--half">
|
||||||
<label for="https_port">HTTPS-Port</label>
|
<label for="https_port">HTTPS-Port {{ hi.hint_icon("Let's Encrypt validiert IMMER über Port 80 (protokollbedingt, unabhängig vom hier eingestellten HTTP-Port) — weicht der HTTP-Port von 80 ab, wird dafür automatisch zusätzlich ein minimaler Port-80-Block mitgeschrieben.", "HTTP-Port / HTTPS-Port") }}</label>
|
||||||
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
|
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-hint" style="margin-top:-8px; margin-bottom:14px;">
|
|
||||||
Let's Encrypt validiert IMMER über Port 80 (protokollbedingt, unabhängig vom hier eingestellten HTTP-Port) —
|
|
||||||
weicht der HTTP-Port von 80 ab, wird dafür automatisch zusätzlich ein minimaler Port-80-Block mitgeschrieben.
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||||
<input type="checkbox" name="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}>
|
<input type="checkbox" name="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}>
|
||||||
@@ -68,9 +64,8 @@
|
|||||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||||
<input type="checkbox" name="hsts_enabled" id="hsts_enabled_check" {% if hsts_enabled %}checked{% endif %} {% if not ssl_enabled %}disabled{% endif %}>
|
<input type="checkbox" name="hsts_enabled" id="hsts_enabled_check" {% if hsts_enabled %}checked{% endif %} {% if not ssl_enabled %}disabled{% endif %}>
|
||||||
<span class="track"></span>
|
<span class="track"></span>
|
||||||
<span>HSTS (Strict-Transport-Security)</span>
|
<span>HSTS (Strict-Transport-Security) {{ hi.hint_icon("Weist Browser an, diese Instanz künftig NUR noch über HTTPS aufzurufen — bleibt auch bei einem späteren Zurückschalten auf HTTP im Browser für 1 Jahr bestehen. Nur aktivieren, wenn das Zertifikat dauerhaft gepflegt wird.", "HSTS") }}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="field-hint">Weist Browser an, diese Instanz künftig NUR noch über HTTPS aufzurufen — bleibt auch bei einem späteren Zurückschalten auf HTTP im Browser für 1 Jahr bestehen. Nur aktivieren, wenn das Zertifikat dauerhaft gepflegt wird.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" name="apply_nginx" value="1" class="btn btn-primary btn-block">
|
<button type="submit" name="apply_nginx" value="1" class="btn btn-primary btn-block">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12l2 2 4-4"/><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12l2 2 4-4"/><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/></svg>
|
||||||
@@ -165,9 +160,8 @@
|
|||||||
<input type="file" name="cert_file" id="cert_file" accept=".pem,.crt,.cer" required>
|
<input type="file" name="cert_file" id="cert_file" accept=".pem,.crt,.cer" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="key_file">Privater Schlüssel (PEM, unverschlüsselt)</label>
|
<label for="key_file">Privater Schlüssel (PEM, unverschlüsselt) {{ hi.hint_icon("Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.", "Privater Schlüssel") }}</label>
|
||||||
<input type="file" name="key_file" id="key_file" accept=".pem,.key" required>
|
<input type="file" name="key_file" id="key_file" accept=".pem,.key" required>
|
||||||
<div class="field-hint">Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" name="upload_cert" value="1" class="btn btn-secondary btn-block">
|
<button type="submit" name="upload_cert" value="1" class="btn btn-secondary btn-block">
|
||||||
<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="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
<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="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -102,9 +103,8 @@
|
|||||||
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
|
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
|
||||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>SSH-Port</label>
|
<div class="field"><label>SSH-Port {{ hi.hint_icon("Leer lassen, wenn der Switch den Standard-Port 22 verwendet.", "SSH-Port") }}</label>
|
||||||
<input type="number" name="ssh_port" value="{{ s['ssh_port'] or '' }}" min="1" max="65535" placeholder="22 (Standard)">
|
<input type="number" name="ssh_port" value="{{ s['ssh_port'] or '' }}" min="1" max="65535" placeholder="22 (Standard)">
|
||||||
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Zugangsdaten</label>
|
<label>Zugangsdaten</label>
|
||||||
@@ -162,9 +162,8 @@
|
|||||||
<input type="text" name="ip" required placeholder="z.B. 192.168.1.100">
|
<input type="text" name="ip" required placeholder="z.B. 192.168.1.100">
|
||||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>SSH-Port</label>
|
<div class="field"><label>SSH-Port {{ hi.hint_icon("Leer lassen, wenn der Switch den Standard-Port 22 verwendet.", "SSH-Port") }}</label>
|
||||||
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
||||||
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Zugangsdaten</label>
|
<label>Zugangsdaten</label>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -205,13 +206,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Gruppe</label>
|
<label>Gruppe {{ hi.hint_icon("Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.", "Gruppe") }}</label>
|
||||||
<select name="group_id" id="group_select">
|
<select name="group_id" id="group_select">
|
||||||
<option value="">Keine Gruppe</option>
|
<option value="">Keine Gruppe</option>
|
||||||
{% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %}
|
{% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %}
|
||||||
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
|
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
|
||||||
</select>
|
</select>
|
||||||
<div class="field-hint">Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
Reference in New Issue
Block a user