Live-Log-Fix, Sidebar-Untermenüs für Geräte/Einstellungen, eigenes Konto

- Live-Log-Bug behoben: _latest_log_file() sortierte nach Datei-ctime statt
  nach Dateiname — auf manchen Dateisystemen (u.a. unter WSL2 auf einem
  gemounteten Windows-Laufwerk) unzuverlässig und lieferte nicht immer das
  tatsächlich neueste Logfile. Sortiert jetzt wie der Rest des Codes über
  den chronologisch sortierbaren Dateinamen (rpi-YYYYMMDDHHMMSS.log).
- Navbar umstrukturiert in aufklappbare Gruppen mit Unterpunkten:
  - "Geräte": Clients (bisherige Devices-Seite), Switche, Zugangsdaten
  - "Einstellungen": Benutzer, Gruppen, Systemeinstellungen (Prüfintervall),
    Im-/Export
  - "Logs": Live, Änderungen (vormals Live-Log/Änderungslog)
  Sichtbarkeit gilt jetzt auch pro Unterpunkt: eine Gruppe erscheint nur,
  wenn mindestens ein Unterpunkt für den Benutzer sichtbar ist, und zeigt
  dann auch nur die sichtbaren Unterpunkte.
- Im-/Export als eigene Unterseite mit Export/Import nebeneinander
  (.settings-grid).
- Neue Seite "Mein Konto" (/account, erreichbar über ein Zahnrad-Symbol
  neben dem eigenen Namen in der Sidebar) ersetzt das bisherige Profil-Modal:
  Profil/Passwort/Profilbild als volle Seite, dazu für Admins die
  Navbar-Reihenfolge (aus den Systemeinstellungen hierher verschoben).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 14:44:02 +02:00
co-authored by Claude Sonnet 5
parent a7a23b7c12
commit 37dac64abc
10 changed files with 275 additions and 194 deletions
+35 -24
View File
@@ -44,16 +44,16 @@ Die App ermöglicht:
zuletzt geändert hat. PoE-Neustarts werden bewusst **nicht** geloggt zuletzt geändert hat. PoE-Neustarts werden bewusst **nicht** geloggt
(siehe [Änderungslog](#änderungslog)) (siehe [Änderungslog](#änderungslog))
- **Verschlüsselter Import/Export**: Geräte, Switche und Zugangsdaten lassen - **Verschlüsselter Import/Export**: Geräte, Switche und Zugangsdaten lassen
sich unter Einstellungen als ein mit selbstgewählter Passphrase sich unter Einstellungen → Im-/Export" als ein mit selbstgewählter
verschlüsseltes Bundle exportieren und auf einer neuen Umgebung wieder Passphrase verschlüsseltes Bundle exportieren und auf einer neuen Umgebung
importieren (siehe [Import/Export](#importexport)) wieder importieren (siehe [Import/Export](#importexport))
- **Eigenes Profil**: jeder Benutzer kann über einen Klick auf seinen Namen - **Eigenes Konto**: das Zahnrad-Symbol neben dem eigenen Namen unten in der
in der Sidebar Vor-/Nachname, eigenes Passwort und ein Profilbild pflegen Sidebar führt zu „Mein Konto“ — Vor-/Nachname, eigenes Passwort und
(siehe [Eigenes Profil](#eigenes-profil--profilbild)) Profilbild pflegen (siehe [Eigenes Konto](#eigenes-konto--profilbild))
- **Anpassbare Navigation**: Admins können die Reihenfolge der Sidebar- - **Anpassbare Navigation**: Admins legen dort zusätzlich die Reihenfolge der
Menüpunkte unter Einstellungen per Auf/Ab-Buttons festlegen; jeder Benutzer Sidebar-Hauptpunkte per Auf-/Ab-Buttons fest; jeder Benutzer sieht davon
sieht davon ohnehin nur, wofür er berechtigt ist (siehe ohnehin nur, wofür er berechtigt ist — auch pro Unterpunkt innerhalb einer
[Navigation anpassen](#navigation-anpassen)) Gruppe (siehe [Navigation anpassen](#navigation-anpassen))
## Was ist neu gegenüber dem Original? ## Was ist neu gegenüber dem Original?
@@ -185,24 +185,35 @@ angelegt. Switche referenzieren ihre Zugangsdaten dabei über den Namen (nicht
die interne ID), damit der Import auch bei abweichender ID-Vergabe auf dem die interne ID), damit der Import auch bei abweichender ID-Vergabe auf dem
Zielsystem korrekt verknüpft. Zielsystem korrekt verknüpft.
## Eigenes Profil & Profilbild ## Eigenes Konto & Profilbild
Ein Klick auf den eigenen Namen unten in der Sidebar öffnet „Mein Profil“: Das Zahnrad-Symbol neben dem eigenen Namen unten in der Sidebar führt zu
Vor-/Nachname ändern, ein Profilbild hochladen (PNG/JPG/GIF/WEBP) und das **„Mein Konto“** (`/account`): Vor-/Nachname ändern, ein Profilbild
eigene Passwort ändern (erfordert Eingabe des aktuellen Passworts). Das ist hochladen (PNG/JPG/GIF/WEBP) und das eigene Passwort ändern (erfordert
bewusst getrennt vom Admin-Bereich „Benutzer“, der andere Konten verwaltet. Eingabe des aktuellen Passworts). Das ist bewusst getrennt vom Admin-Bereich
Ist ein Profilbild hinterlegt, erscheint es statt der Initialen sowohl in „Benutzer“, der andere Konten verwaltet. Ist ein Profilbild hinterlegt,
der Sidebar als auch im Änderungslog neben dem Benutzernamen. erscheint es statt der Initialen sowohl in der Sidebar als auch im
Änderungslog neben dem Benutzernamen. Admins sehen auf derselben Seite
zusätzlich die Navbar-Reihenfolge (siehe unten).
## Navigation anpassen ## Navigation anpassen
Die Reihenfolge der Sidebar-Menüpunkte ist unter **Einstellungen** per Die Sidebar ist in drei aufklappbare Gruppen strukturiert:
Auf-/Ab-Buttons konfigurierbar (gespeichert als JSON-Liste unter
`settings.nav_order`) und gilt global für alle Benutzer. Unabhängig von der - **Geräte** — Clients (die eigentlichen Devices), Switche, Zugangsdaten
Reihenfolge sieht jeder Benutzer ohnehin nur die Punkte, für die er auch - **Einstellungen** — Benutzer, Gruppen, Systemeinstellungen (Prüfintervall),
berechtigt ist — ein Benutzer ohne jede Gruppe sieht z.B. nur „Dashboard“. Im-/Export
„Logs“ ist dabei eine aufklappbare Gruppe mit den Unterpunkten „Live-Log - **Logs** — Live, Änderungen (Änderungslog)
und „Änderungslog“.
Die Reihenfolge dieser drei Gruppen (und von „Dashboard“) ist für Admins
unter **Mein Konto** (Zahnrad-Symbol in der Sidebar) per Auf-/Ab-Buttons
konfigurierbar (gespeichert als JSON-Liste unter `settings.nav_order`) und
gilt global für alle Benutzer. Unabhängig von der Reihenfolge sieht jeder
Benutzer ohnehin nur, wofür er berechtigt ist — sowohl auf Gruppenebene als
auch pro Unterpunkt: hat ein Benutzer z.B. nur `devices.view`, erscheint die
Gruppe „Geräte“ nur mit dem Unterpunkt „Clients“ (ohne Switche/
Zugangsdaten); eine Gruppe verschwindet komplett, wenn kein Unterpunkt
sichtbar ist.
## Entwicklung unter WSL ## Entwicklung unter WSL
+60 -22
View File
@@ -150,36 +150,43 @@ DEFAULT_GROUP_NAME = "Benutzer"
DEFAULT_GROUP_PERMISSIONS = ["devices.view", "switches.view"] DEFAULT_GROUP_PERMISSIONS = ["devices.view", "switches.view"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Navbar — Reihenfolge ist admin-konfigurierbar (siehe settings(), gespeichert # Navbar — die Reihenfolge der TOP-LEVEL-Punkte ist admin-konfigurierbar
# als JSON-Liste von Keys unter settings.nav_order); standardmäßig wird pro # (siehe /account, gespeichert als JSON-Liste von Keys unter
# Benutzer nur angezeigt, wofür er auch tatsächlich eine Berechtigung hat. # settings.nav_order); standardmäßig wird pro Benutzer nur angezeigt, wofür
# "logs" ist eine Gruppe mit Untermenü (Live-Log + Änderungslog). # er auch tatsächlich eine Berechtigung hat — das gilt auch pro Unterpunkt
# innerhalb einer Gruppe: eine Gruppe erscheint nur, wenn mindestens ein
# Unterpunkt sichtbar ist, und zeigt dann auch nur die sichtbaren Unterpunkte.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
NAV_ITEMS = [ NAV_ITEMS = [
{"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"}, {"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"},
{"key": "devices", "label": "Geräte", "icon": "cpu", "endpoint": "devices"}, {"key": "devices_group", "label": "Geräte", "icon": "cpu", "children": [
{"key": "devices", "label": "Clients", "icon": "cpu", "endpoint": "devices"},
{"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"}, {"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"},
{"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"}, {"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"},
]},
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"}, {"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"}, {"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
{"key": "logs", "label": "Logs", "icon": "terminal", "children": [ {"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
{"key": "logs_live", "label": "Live-Log", "icon": "terminal", "endpoint": "logs"}, {"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"},
{"key": "logs_activity", "label": "Änderungslog", "icon": "history", "endpoint": "activity_log"}, ]},
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
{"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
{"key": "logs_activity", "label": "Änderungen", "icon": "history", "endpoint": "activity_log"},
]}, ]},
{"key": "settings", "label": "Einstellungen", "icon": "sliders", "endpoint": "settings"},
] ]
DEFAULT_NAV_ORDER = [item["key"] for item in NAV_ITEMS] DEFAULT_NAV_ORDER = [item["key"] for item in NAV_ITEMS]
NAV_ITEMS_BY_KEY = {item["key"]: item for item in NAV_ITEMS} NAV_ITEMS_BY_KEY = {item["key"]: item for item in NAV_ITEMS}
def _nav_item_visible(key, user): def _nav_key_visible(key, user):
if key == "index": if key == "index":
return True return True
if key == "devices": if key == "devices":
return user.can_view_devices return user.can_view_devices
if key in ("switches", "credentials"): if key in ("switches", "credentials"):
return user.can_manage_switches return user.can_manage_switches
if key in ("users", "groups", "logs", "settings"): if key in ("users", "groups", "settings_system", "settings_importexport", "logs_live", "logs_activity"):
return user.is_admin return user.is_admin
return False return False
@@ -194,7 +201,16 @@ def inject_nav():
stored_order = [] stored_order = []
ordered_keys = [k for k in stored_order if k in NAV_ITEMS_BY_KEY] ordered_keys = [k for k in stored_order if k in NAV_ITEMS_BY_KEY]
ordered_keys += [k for k in DEFAULT_NAV_ORDER if k not in ordered_keys] ordered_keys += [k for k in DEFAULT_NAV_ORDER if k not in ordered_keys]
nav_items_ordered = [NAV_ITEMS_BY_KEY[k] for k in ordered_keys if _nav_item_visible(k, current_user)]
nav_items_ordered = []
for k in ordered_keys:
item = NAV_ITEMS_BY_KEY[k]
if item.get("children"):
visible_children = [c for c in item["children"] if _nav_key_visible(c["key"], current_user)]
if visible_children:
nav_items_ordered.append({**item, "children": visible_children})
elif _nav_key_visible(k, current_user):
nav_items_ordered.append(item)
return {"nav_items_ordered": nav_items_ordered} return {"nav_items_ordered": nav_items_ordered}
@@ -507,12 +523,19 @@ def logout():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Eigenes Profil — jeder eingeloggte Benutzer darf Vor-/Nachname, eigenes # Eigenes Profil / Konto — jeder eingeloggte Benutzer darf Vor-/Nachname,
# Passwort und Profilbild selbst ändern (Klick auf den eigenen Namen in der # eigenes Passwort und Profilbild selbst ändern (Zahnrad-Symbol neben dem
# Sidebar). Bewusst getrennt vom Admin-"Users"-Bereich, der andere Benutzer # eigenen Namen unten in der Sidebar). Bewusst getrennt vom Admin-"Users"-
# verwaltet. # Bereich, der andere Benutzer verwaltet. Admins konfigurieren hier
# zusätzlich die Navbar-Reihenfolge (siehe save_nav_order()).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.route("/account")
@login_required
def account():
return render_template("account.html")
@app.route("/profile", methods=["POST"]) @app.route("/profile", methods=["POST"])
@login_required @login_required
def profile(): def profile():
@@ -576,10 +599,16 @@ def profile():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _latest_log_file(): def _latest_log_file():
"""Neuestes Logfile ermitteln. Bewusst über den Dateinamen sortiert
(rpi-YYYYMMDDHHMMSS.log ist lexikografisch = chronologisch sortierbar),
NICHT über Dateisystem-Metadaten wie ctime/mtime — die sind je nach
Dateisystem (z.B. unter WSL2 auf einem gemounteten Windows-Laufwerk)
unzuverlässig und haben in der Praxis nicht immer das tatsächlich
zuletzt geschriebene Logfile geliefert."""
log_files = glob.glob(LOG_GLOB) log_files = glob.glob(LOG_GLOB)
if not log_files: if not log_files:
return None return None
return max(log_files, key=os.path.getctime) return max(log_files)
def get_last_seen(dev_name: str): def get_last_seen(dev_name: str):
@@ -758,6 +787,15 @@ def settings():
return render_template("settings.html", interval=interval) return render_template("settings.html", interval=interval)
@app.route("/settings/import-export")
@login_required
def settings_import_export():
if not current_user.is_admin:
flash("Nur Admins dürfen Daten importieren/exportieren!", "danger")
return redirect(url_for("index"))
return render_template("settings_import_export.html")
@app.route("/settings/nav-order", methods=["POST"]) @app.route("/settings/nav-order", methods=["POST"])
@login_required @login_required
def save_nav_order(): def save_nav_order():
@@ -770,7 +808,7 @@ def save_nav_order():
set_setting("nav_order", json.dumps(order)) set_setting("nav_order", json.dumps(order))
log_action("settings.update", "Navbar-Reihenfolge") log_action("settings.update", "Navbar-Reihenfolge")
flash("Navbar-Reihenfolge gespeichert.", "success") flash("Navbar-Reihenfolge gespeichert.", "success")
return redirect(url_for("settings")) return redirect(url_for("account"))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -791,7 +829,7 @@ def export_data():
passphrase = request.form.get("export_passphrase", "") passphrase = request.form.get("export_passphrase", "")
if not passphrase: if not passphrase:
flash("Bitte eine Passphrase für den Export angeben.", "danger") flash("Bitte eine Passphrase für den Export angeben.", "danger")
return redirect(url_for("settings")) return redirect(url_for("settings_import_export"))
conn = get_db_connection() conn = get_db_connection()
credentials_rows = conn.execute("SELECT name, username, password FROM credentials").fetchall() credentials_rows = conn.execute("SELECT name, username, password FROM credentials").fetchall()
@@ -850,7 +888,7 @@ def import_data():
upload = request.files.get("import_file") upload = request.files.get("import_file")
if not passphrase or not upload or not upload.filename: if not passphrase or not upload or not upload.filename:
flash("Bitte Passphrase und Export-Datei angeben.", "danger") flash("Bitte Passphrase und Export-Datei angeben.", "danger")
return redirect(url_for("settings")) return redirect(url_for("settings_import_export"))
try: try:
envelope = json.loads(upload.read().decode("utf-8")) envelope = json.loads(upload.read().decode("utf-8"))
@@ -859,7 +897,7 @@ def import_data():
payload = json.loads(export_fernet.decrypt(envelope["payload"].encode("utf-8")).decode("utf-8")) payload = json.loads(export_fernet.decrypt(envelope["payload"].encode("utf-8")).decode("utf-8"))
except Exception: except Exception:
flash("Import fehlgeschlagen: Datei ungültig oder Passphrase falsch.", "danger") flash("Import fehlgeschlagen: Datei ungültig oder Passphrase falsch.", "danger")
return redirect(url_for("settings")) return redirect(url_for("settings_import_export"))
conn = get_db_connection() conn = get_db_connection()
n_cred = n_switch = n_dev = 0 n_cred = n_switch = n_dev = 0
@@ -898,7 +936,7 @@ def import_data():
log_action("data.import", "Devices/Switches/Zugangsdaten", f"{n_dev} Geräte, {n_switch} Switche, {n_cred} Zugangsdaten") log_action("data.import", "Devices/Switches/Zugangsdaten", f"{n_dev} Geräte, {n_switch} Switche, {n_cred} Zugangsdaten")
flash(f"Import abgeschlossen: {n_cred} Zugangsdaten, {n_switch} Switche, {n_dev} Geräte.", "success") flash(f"Import abgeschlossen: {n_cred} Zugangsdaten, {n_switch} Switche, {n_dev} Geräte.", "success")
return redirect(url_for("settings")) return redirect(url_for("settings_import_export"))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+11 -1
View File
@@ -200,6 +200,14 @@ button { font-family: inherit; }
.nav-group.expanded .nav-group-children { display: flex; } .nav-group.expanded .nav-group-children { display: flex; }
.nav-group-children .nav-item { font-size: 13px; padding: 8px 12px; } .nav-group-children .nav-item { font-size: 13px; padding: 8px 12px; }
/* Kacheln nebeneinander (z.B. Im-/Export, Konto-Seite) */
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 24px;
align-items: start;
}
/* Navbar-Reihenfolge (Settings) */ /* Navbar-Reihenfolge (Settings) */
.nav-order-list { .nav-order-list {
list-style: none; list-style: none;
@@ -247,7 +255,9 @@ button { font-family: inherit; }
flex-shrink: 0; flex-shrink: 0;
} }
.user-meta { overflow: hidden; } .user-meta { flex: 1; min-width: 0; overflow: hidden; }
.user-chip .icon-btn { flex-shrink: 0; width: 30px; height: 30px; }
.user-chip .icon-btn svg { width: 15px; height: 15px; }
.user-meta .u-name { font-size: 13px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .user-meta .u-name { font-size: 13px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-meta .u-role { font-size: 11px; color: var(--text-faint); } .user-meta .u-role { font-size: 11px; color: var(--text-faint); }
+99
View File
@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block page_title %}Mein Konto{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ current_user.username }}</div>{% endblock %}
{% block content %}
{% if current_user.is_admin %}
<div class="card card-pad" style="margin-bottom:24px;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Navbar-Reihenfolge</h2>
<div class="hint">Bestimmt, in welcher Reihenfolge die Menüpunkte in der Sidebar erscheinen. Jeder Benutzer sieht davon nur, wofür er auch berechtigt ist.</div>
</div>
</div>
<form method="post" action="{{ url_for('save_nav_order') }}" id="navOrderForm">
<ul class="nav-order-list" id="navOrderList">
{% for item in nav_items_ordered %}
<li data-key="{{ item.key }}">
<span>{{ item.label }}</span>
<div class="nav-order-actions">
<button type="button" class="icon-btn" title="Nach oben" onclick="moveNavItem(this,-1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
</button>
<button type="button" class="icon-btn" title="Nach unten" onclick="moveNavItem(this,1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
</button>
</div>
<input type="hidden" name="nav_order" value="{{ item.key }}">
</li>
{% endfor %}
</ul>
<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>
Reihenfolge speichern
</button>
</form>
</div>
{% endif %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Profil</h2>
<div class="hint">Name und Profilbild ändern sich in der Sidebar und im Änderungslog.</div>
</div>
</div>
<div style="display:flex; align-items:center; gap:14px; margin-bottom:20px;">
{% if current_user.avatar_url %}
<img src="{{ current_user.avatar_url }}" alt="" style="width:56px; height:56px; border-radius:50%; object-fit:cover;">
{% else %}
<div class="user-avatar" style="width:56px; height:56px; font-size:18px;">{{ current_user.username[:2]|upper }}</div>
{% endif %}
<form method="post" action="{{ url_for('profile') }}" enctype="multipart/form-data" id="avatarForm" style="flex:1;">
<input type="file" name="avatar" id="avatarInput" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none;" onchange="document.getElementById('avatarForm').requestSubmit();">
<button type="button" class="btn btn-secondary" style="width:100%;" onclick="document.getElementById('avatarInput').click();">Profilbild ändern</button>
<input type="hidden" name="upload_avatar" value="1">
</form>
</div>
<form method="post" action="{{ url_for('profile') }}" id="profileForm">
<div class="field"><label>Vorname</label><input type="text" name="first_name" value="{{ current_user.first_name or '' }}"></div>
<div class="field"><label>Name</label><input type="text" name="last_name" value="{{ current_user.last_name or '' }}"></div>
<div class="field"><label>Username</label><input type="text" value="{{ current_user.username }}" disabled></div>
<button type="submit" name="update_profile" value="1" class="btn btn-primary btn-block">Speichern</button>
</form>
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Passwort ändern</h2>
<div class="hint">Erfordert Eingabe des aktuellen Passworts.</div>
</div>
</div>
<form method="post" action="{{ url_for('profile') }}" id="passwordForm">
<div class="field"><label>Aktuelles Passwort</label><input type="password" name="current_password" autocomplete="current-password"></div>
<div class="field"><label>Neues Passwort</label><input type="password" name="new_password" autocomplete="new-password"></div>
<div class="field"><label>Neues Passwort bestätigen</label><input type="password" name="confirm_password" autocomplete="new-password"></div>
<button type="submit" name="change_password" value="1" class="btn btn-secondary btn-block">Passwort ändern</button>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function moveNavItem(btn, dir) {
const li = btn.closest("li");
const target = dir === -1 ? li.previousElementSibling : li.nextElementSibling;
if (!target) return;
if (dir === -1) li.parentNode.insertBefore(li, target);
else li.parentNode.insertBefore(target, li);
}
</script>
{% endblock %}
+1 -1
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "logs" %} {% set active_page = "logs" %}
{% block page_title %}Änderungslog{% endblock %} {% block page_title %}Änderungen{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %}
{% block content %} {% block content %}
+7 -51
View File
@@ -21,6 +21,8 @@
"history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>', "history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>', "sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>', "logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
"transfer": '<path d="M17 3l4 4-4 4"/><path d="M3 7h18"/><path d="M7 21l-4-4 4-4"/><path d="M21 17H3"/>',
} %} } %}
<div class="app-shell"> <div class="app-shell">
@@ -65,7 +67,7 @@
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button type="button" class="user-chip" data-open-modal="profileModal" onclick="resetProfileForms();" style="cursor:pointer; text-align:left; background:none; border:none; width:100%;"> <div class="user-chip">
{% if current_user.avatar_url %} {% if current_user.avatar_url %}
<img class="user-avatar" src="{{ current_user.avatar_url }}" alt="" style="object-fit:cover;"> <img class="user-avatar" src="{{ current_user.avatar_url }}" alt="" style="object-fit:cover;">
{% else %} {% else %}
@@ -75,7 +77,10 @@
<div class="u-name">{{ current_user.display_name }}</div> <div class="u-name">{{ current_user.display_name }}</div>
<div class="u-role">{{ "Administrator" if current_user.is_admin else current_user.group_names or "Benutzer" }}</div> <div class="u-role">{{ "Administrator" if current_user.is_admin else current_user.group_names or "Benutzer" }}</div>
</div> </div>
</button> <a href="{{ url_for('account') }}" class="icon-btn" title="Einstellungen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['gear']|safe }}</svg>
</a>
</div>
<div class="footer-actions"> <div class="footer-actions">
<button type="button" class="icon-btn" data-theme-toggle title="Theme wechseln"> <button type="button" class="icon-btn" data-theme-toggle title="Theme wechseln">
<span data-theme-icon></span> <span data-theme-icon></span>
@@ -86,49 +91,6 @@
</div> </div>
</div> </div>
</aside> </aside>
<!-- Modal: Mein Profil (Klick auf eigenen Namen in der Sidebar) -->
<div class="modal-overlay" id="profileModal">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h3>Mein Profil</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div style="display:flex; align-items:center; gap:14px; margin-bottom:20px;">
{% if current_user.avatar_url %}
<img src="{{ current_user.avatar_url }}" alt="" style="width:56px; height:56px; border-radius:50%; object-fit:cover;">
{% else %}
<div class="user-avatar" style="width:56px; height:56px; font-size:18px;">{{ current_user.username[:2]|upper }}</div>
{% endif %}
<form method="post" action="{{ url_for('profile') }}" enctype="multipart/form-data" id="avatarForm" style="flex:1;">
<input type="file" name="avatar" id="avatarInput" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none;" onchange="document.getElementById('avatarForm').requestSubmit();">
<button type="button" class="btn btn-secondary" style="width:100%;" onclick="document.getElementById('avatarInput').click();">Profilbild ändern</button>
<input type="hidden" name="upload_avatar" value="1">
</form>
</div>
<form method="post" action="{{ url_for('profile') }}" id="profileForm">
<div class="field"><label>Vorname</label><input type="text" name="first_name" value="{{ current_user.first_name or '' }}"></div>
<div class="field"><label>Name</label><input type="text" name="last_name" value="{{ current_user.last_name or '' }}"></div>
<div class="field"><label>Username</label><input type="text" value="{{ current_user.username }}" disabled></div>
<button type="submit" name="update_profile" value="1" class="btn btn-primary btn-block">Speichern</button>
</form>
<div style="border-top:1px solid var(--border); margin:20px 0;"></div>
<form method="post" action="{{ url_for('profile') }}" id="passwordForm">
<h4 style="font-size:13px; text-transform:uppercase; letter-spacing:.04em; color:var(--text-dim); margin-bottom:12px;">Passwort ändern</h4>
<div class="field"><label>Aktuelles Passwort</label><input type="password" name="current_password" autocomplete="current-password"></div>
<div class="field"><label>Neues Passwort</label><input type="password" name="new_password" autocomplete="new-password"></div>
<div class="field"><label>Neues Passwort bestätigen</label><input type="password" name="confirm_password" autocomplete="new-password"></div>
<button type="submit" name="change_password" value="1" class="btn btn-secondary btn-block">Passwort ändern</button>
</form>
</div>
</div>
</div>
{% endif %} {% endif %}
<div class="main {% if not current_user.is_authenticated %}no-sidebar{% endif %}"> <div class="main {% if not current_user.is_authenticated %}no-sidebar{% endif %}">
@@ -171,12 +133,6 @@
{% endwith %} {% endwith %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script> <script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
function resetProfileForms() {
const pwForm = document.getElementById("passwordForm");
if (pwForm) pwForm.reset();
}
</script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+1 -1
View File
@@ -6,7 +6,7 @@
{% set can_delete = current_user.has_permission('devices.delete') %} {% set can_delete = current_user.has_permission('devices.delete') %}
{% set show_actions_col = can_edit or can_delete %} {% set show_actions_col = can_edit or can_delete %}
{% set col_count = 5 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %} {% set col_count = 5 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %}
{% block page_title %}Geräte{% endblock %} {% block page_title %}Clients{% endblock %}
{% 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 topbar_right %} {% block topbar_right %}
{% if can_create %} {% if can_create %}
+1 -1
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "logs" %} {% set active_page = "logs" %}
{% block page_title %}Live-Log{% endblock %} {% block page_title %}Live{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ log_name or "kein Logfile" }}</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ log_name or "kein Logfile" }}</div>{% endblock %}
{% block topbar_right %} {% block topbar_right %}
<span class="timer-pill"><span class="dot"></span>Update in <span id="timer">--</span>s</span> <span class="timer-pill"><span class="dot"></span>Update in <span id="timer">--</span>s</span>
+2 -89
View File
@@ -1,41 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "settings" %} {% set active_page = "settings_system" %}
{% block page_title %}Einstellungen{% endblock %} {% block page_title %}Systemeinstellungen{% endblock %}
{% block page_sub %}<div class="topbar-sub">Prüfintervall für das Monitoring</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Prüfintervall für das Monitoring</div>{% endblock %}
{% block content %} {% block content %}
<div class="card card-pad" style="max-width:420px;"> <div class="card card-pad" style="max-width:420px;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Navbar-Reihenfolge</h2>
<div class="hint">Bestimmt, in welcher Reihenfolge die Menüpunkte in der Sidebar erscheinen. Jeder Benutzer sieht davon nur, wofür er auch berechtigt ist.</div>
</div>
</div>
<form method="post" action="{{ url_for('save_nav_order') }}" id="navOrderForm">
<ul class="nav-order-list" id="navOrderList">
{% for item in nav_items_ordered %}
<li data-key="{{ item.key }}">
<span>{{ item.label }}</span>
<div class="nav-order-actions">
<button type="button" class="icon-btn" title="Nach oben" onclick="moveNavItem(this,-1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
</button>
<button type="button" class="icon-btn" title="Nach unten" onclick="moveNavItem(this,1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
</button>
</div>
<input type="hidden" name="nav_order" value="{{ item.key }}">
</li>
{% endfor %}
</ul>
<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>
Reihenfolge speichern
</button>
</form>
</div>
<div class="card card-pad" style="max-width:420px; margin-top:24px;">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Prüfintervall</h2> <h2 style="font-size:16px;">Prüfintervall</h2>
@@ -54,60 +23,4 @@
</button> </button>
</form> </form>
</div> </div>
<div class="card card-pad" style="max-width:420px; margin-top:24px;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Export</h2>
<div class="hint">Geräte, Switche und Zugangsdaten als verschlüsseltes Bundle sichern — z.B. für einen Umzug auf eine neue Umgebung.</div>
</div>
</div>
<form method="post" action="{{ url_for('export_data') }}">
<div class="field">
<label for="export_passphrase">Passphrase</label>
<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>
<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>
Export herunterladen
</button>
</form>
</div>
<div class="card card-pad" style="max-width:420px; margin-top:24px;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Import</h2>
<div class="hint">Ein zuvor exportiertes Bundle einlesen. Bestehende Einträge mit gleichem Namen/Hostname/MAC werden aktualisiert, neue werden angelegt.</div>
</div>
</div>
<form method="post" action="{{ url_for('import_data') }}" enctype="multipart/form-data"
data-confirm="Import wirklich starten? Bestehende Einträge mit gleichem Namen/Hostname/MAC werden überschrieben.">
<div class="field">
<label for="import_file">Export-Datei</label>
<input type="file" name="import_file" id="import_file" accept=".json" required>
</div>
<div class="field">
<label for="import_passphrase">Passphrase</label>
<input type="password" name="import_passphrase" id="import_passphrase" required>
</div>
<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 9V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4"/><path d="M7 14l5-5 5 5"/><path d="M12 9v12"/></svg>
Import starten
</button>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
function moveNavItem(btn, dir) {
const li = btn.closest("li");
const target = dir === -1 ? li.previousElementSibling : li.nextElementSibling;
if (!target) return;
if (dir === -1) li.parentNode.insertBefore(li, target);
else li.parentNode.insertBefore(target, li);
}
</script>
{% endblock %} {% endblock %}
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% set active_page = "settings_importexport" %}
{% block page_title %}Im-/Export{% endblock %}
{% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %}
{% block content %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Export</h2>
<div class="hint">Geräte, Switche und Zugangsdaten als verschlüsseltes Bundle sichern — z.B. für einen Umzug auf eine neue Umgebung.</div>
</div>
</div>
<form method="post" action="{{ url_for('export_data') }}">
<div class="field">
<label for="export_passphrase">Passphrase</label>
<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>
<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>
Export herunterladen
</button>
</form>
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Import</h2>
<div class="hint">Ein zuvor exportiertes Bundle einlesen. Bestehende Einträge mit gleichem Namen/Hostname/MAC werden aktualisiert, neue werden angelegt.</div>
</div>
</div>
<form method="post" action="{{ url_for('import_data') }}" enctype="multipart/form-data"
data-confirm="Import wirklich starten? Bestehende Einträge mit gleichem Namen/Hostname/MAC werden überschrieben.">
<div class="field">
<label for="import_file">Export-Datei</label>
<input type="file" name="import_file" id="import_file" accept=".json" required>
</div>
<div class="field">
<label for="import_passphrase">Passphrase</label>
<input type="password" name="import_passphrase" id="import_passphrase" required>
</div>
<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 9V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4"/><path d="M7 14l5-5 5 5"/><path d="M12 9v12"/></svg>
Import starten
</button>
</form>
</div>
</div>
{% endblock %}