Audit-Log, Import/Export, eigenes Profil & anpassbare Navigation

- Änderungslog: audit_log-Tabelle + last_modified_by/at an Devices und
  Switches; log_action()/touch_record() in allen Verwaltungsrouten
  (Devices, Switches, Zugangsdaten, Benutzer, Gruppen, Settings) verdrahtet.
  Neue Admin-Seite "Änderungslog" unter /logs/aenderungen. PoE-Neustarts
  werden bewusst nicht geloggt.
- Verschlüsseltes Import/Export für Devices/Switches/Zugangsdaten unter
  Settings, passphrasenbasiert (PBKDF2 + Fernet), für Umzug auf neue
  Umgebungen. Referenziert Zugangsdaten/Switche über Name/Hostname statt
  interner ID für stabilen Re-Import.
- Eigenes Profil: Klick auf den Namen in der Sidebar öffnet ein Modal zum
  Ändern von Vor-/Nachname, eigenem Passwort (mit Prüfung des aktuellen
  Passworts) und Profilbild-Upload (Anzeige in Sidebar + Änderungslog).
- Anpassbare Navigation: Reihenfolge der Sidebar-Punkte ist unter Settings
  per Auf-/Ab-Buttons konfigurierbar (settings.nav_order); "Devices"/
  "Users"/"Settings" umbenannt zu "Geräte"/"Benutzer"/"Einstellungen";
  "Live-Log" und "Änderungslog" zu aufklappbarer "Logs"-Gruppe
  zusammengefasst. Jeder Benutzer sieht weiterhin nur, wofür er berechtigt
  ist.
- create_db.py mit allen Schema-Erweiterungen synchronisiert (audit_log,
  last_modified_by/at, avatar_filename) für Frischinstallationen.
- venv-Umgebung im WSL neu aufgesetzt (war fälschlich unter dem
  Erstellungspfad venv-linux verankert und daher nicht aktivierbar).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 14:27:46 +02:00
co-authored by Claude Sonnet 5
parent fa6bb2935f
commit a7a23b7c12
13 changed files with 828 additions and 63 deletions
+3
View File
@@ -11,3 +11,6 @@ __pycache__/
/srv/poe_manager/fernet.key
/srv/poe_manager/secret.key
/srv/poe_manager/known_hosts
# Laufzeit-Uploads (Profilbilder) — werden bei Bedarf automatisch angelegt
/srv/poe_manager/static/uploads/
+68
View File
@@ -37,6 +37,23 @@ Die App ermöglicht:
(siehe [Rechtesystem](#rechtesystem-gruppen--berechtigungen))
- Einklappbare Sidebar (Desktop: dauerhaft, über Neuladen hinweg gemerkt;
mobil: temporäres Überlagern)
- **Änderungslog**: jede Anlage/Bearbeitung/Löschung sowie Aktivieren/
Deaktivieren von Geräten, Switchen, Zugangsdaten, Benutzern und Gruppen
wird protokolliert (wer, wann, was) — einsehbar unter „Logs → Änderungslog“;
Geräte und Switche merken sich zusätzlich direkt am Datensatz, wer sie
zuletzt geändert hat. PoE-Neustarts werden bewusst **nicht** geloggt
(siehe [Änderungslog](#änderungslog))
- **Verschlüsselter Import/Export**: Geräte, Switche und Zugangsdaten lassen
sich unter Einstellungen als ein mit selbstgewählter Passphrase
verschlüsseltes Bundle exportieren und auf einer neuen Umgebung wieder
importieren (siehe [Import/Export](#importexport))
- **Eigenes Profil**: jeder Benutzer kann über einen Klick auf seinen Namen
in der Sidebar Vor-/Nachname, eigenes Passwort und ein Profilbild pflegen
(siehe [Eigenes Profil](#eigenes-profil--profilbild))
- **Anpassbare Navigation**: Admins können die Reihenfolge der Sidebar-
Menüpunkte unter Einstellungen per Auf/Ab-Buttons festlegen; jeder Benutzer
sieht davon ohnehin nur, wofür er berechtigt ist (siehe
[Navigation anpassen](#navigation-anpassen))
## Was ist neu gegenüber dem Original?
@@ -136,6 +153,57 @@ Löschen ist nur möglich, wenn kein Switch mehr auf den Eintrag verweist.
Bestehende Datenbanken aus einer älteren Version (Username/Passwort direkt
am Switch) werden beim ersten Start automatisch migriert.
## Änderungslog
Jede Anlage, Bearbeitung, Löschung sowie jedes Aktivieren/Deaktivieren von
Geräten, Switchen, Zugangsdaten, Benutzern und Gruppen wird in der Tabelle
`audit_log` protokolliert (Zeitpunkt, Benutzer, Aktion, Ziel, Details) —
einsehbar unter **Logs → Änderungslog** (nur für Admins). Zusätzlich merken
sich Geräte und Switche direkt am Datensatz (`last_modified_by`,
`last_modified_at`), wer sie zuletzt geändert hat, damit man das nicht erst
im Log nachschlagen muss.
PoE-Neustarts werden **bewusst nicht** im Änderungslog erfasst — die stehen
bereits im Live-Log von `poe.sh`. Eine Ausnahme ist die Änderung des
Prüfintervalls selbst (eine Einstellungsänderung, kein Neustart), die wird
mitprotokolliert.
## Import/Export
Unter **Einstellungen** lassen sich Geräte, Switche und Zugangsdaten
(inkl. entschlüsselter Passwörter) als ein einziges JSON-Bundle exportieren.
Das Bundle wird mit einem aus einer selbstgewählten Passphrase abgeleiteten
Schlüssel verschlüsselt (PBKDF2-HMAC-SHA256 + Fernet) — unabhängig vom
`fernet.key` dieser Installation, damit die Datei auf einer komplett neuen
Umgebung (mit eigenem, neu generiertem `fernet.key`) wieder eingelesen
werden kann. Gedacht für einen Umzug auf einen neuen Server.
Beim Import wird dieselbe Passphrase erneut abgefragt; Zugangsdaten werden
nach Name, Switche nach Hostname und Geräte nach MAC-Adresse abgeglichen —
bestehende Einträge mit gleichem Schlüssel werden aktualisiert, neue werden
angelegt. Switche referenzieren ihre Zugangsdaten dabei über den Namen (nicht
die interne ID), damit der Import auch bei abweichender ID-Vergabe auf dem
Zielsystem korrekt verknüpft.
## Eigenes Profil & Profilbild
Ein Klick auf den eigenen Namen unten in der Sidebar öffnet „Mein Profil“:
Vor-/Nachname ändern, ein Profilbild hochladen (PNG/JPG/GIF/WEBP) und das
eigene Passwort ändern (erfordert Eingabe des aktuellen Passworts). Das ist
bewusst getrennt vom Admin-Bereich „Benutzer“, der andere Konten verwaltet.
Ist ein Profilbild hinterlegt, erscheint es statt der Initialen sowohl in
der Sidebar als auch im Änderungslog neben dem Benutzernamen.
## Navigation anpassen
Die Reihenfolge der Sidebar-Menüpunkte ist unter **Einstellungen** 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 die Punkte, für die er auch
berechtigt ist — ein Benutzer ohne jede Gruppe sieht z.B. nur „Dashboard“.
„Logs“ ist dabei eine aufklappbare Gruppe mit den Unterpunkten „Live-Log“
und „Änderungslog“.
## Entwicklung unter WSL
Da das Projekt ausschließlich für Linux gebaut ist, empfiehlt sich die
+404 -6
View File
@@ -8,13 +8,16 @@ Switch-/User-Verwaltung, Live-Log, Settings, manueller PoE-Neustart),
lediglich mit modernisiertem Frontend und aufgeräumten/konfigurierbaren
Pfaden im Backend.
"""
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_from_directory
from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user
from flask_bcrypt import Bcrypt
from flask_sock import Sock
from werkzeug.utils import secure_filename
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from datetime import datetime
import sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
import base64, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
import paramiko
@@ -73,6 +76,9 @@ LOG_GLOB = os.environ.get("POE_LOG_GLOB", "/var/log/rpi-*.log")
FERNET_KEY_PATH = os.environ.get("POE_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key"))
SSH_KNOWN_HOSTS_PATH = os.environ.get("POE_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts"))
SECRET_KEY_PATH = os.environ.get("POE_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key"))
AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars")
ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"}
os.makedirs(AVATAR_DIR, exist_ok=True)
app = Flask(__name__)
@@ -143,14 +149,66 @@ ALL_PERMISSION_KEYS = [key for cat in PERMISSIONS.values() for key in cat["items
DEFAULT_GROUP_NAME = "Benutzer"
DEFAULT_GROUP_PERMISSIONS = ["devices.view", "switches.view"]
# ---------------------------------------------------------------------------
# Navbar — Reihenfolge ist admin-konfigurierbar (siehe settings(), gespeichert
# als JSON-Liste von Keys unter settings.nav_order); standardmäßig wird pro
# Benutzer nur angezeigt, wofür er auch tatsächlich eine Berechtigung hat.
# "logs" ist eine Gruppe mit Untermenü (Live-Log + Änderungslog).
# ---------------------------------------------------------------------------
NAV_ITEMS = [
{"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"},
{"key": "devices", "label": "Geräte", "icon": "cpu", "endpoint": "devices"},
{"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"},
{"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"},
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
{"key": "logs", "label": "Logs", "icon": "terminal", "children": [
{"key": "logs_live", "label": "Live-Log", "icon": "terminal", "endpoint": "logs"},
{"key": "logs_activity", "label": "Änderungslog", "icon": "history", "endpoint": "activity_log"},
]},
{"key": "settings", "label": "Einstellungen", "icon": "sliders", "endpoint": "settings"},
]
DEFAULT_NAV_ORDER = [item["key"] for item in NAV_ITEMS]
NAV_ITEMS_BY_KEY = {item["key"]: item for item in NAV_ITEMS}
def _nav_item_visible(key, user):
if key == "index":
return True
if key == "devices":
return user.can_view_devices
if key in ("switches", "credentials"):
return user.can_manage_switches
if key in ("users", "groups", "logs", "settings"):
return user.is_admin
return False
@app.context_processor
def inject_nav():
if not current_user.is_authenticated:
return {}
try:
stored_order = json.loads(get_setting("nav_order", "") or "[]")
except (ValueError, TypeError):
stored_order = []
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]
nav_items_ordered = [NAV_ITEMS_BY_KEY[k] for k in ordered_keys if _nav_item_visible(k, current_user)]
return {"nav_items_ordered": nav_items_ordered}
class User(UserMixin):
def __init__(self, id_, username, is_admin, permissions=None, group_names=None):
def __init__(self, id_, username, is_admin, permissions=None, group_names=None,
first_name=None, last_name=None, avatar_filename=None):
self.id = id_
self.username = username
self.is_admin = bool(is_admin)
self.permissions = permissions or set()
self.group_names = group_names or ""
self.first_name = first_name
self.last_name = last_name
self.avatar_filename = avatar_filename
def has_permission(self, key):
return self.is_admin or key in self.permissions
@@ -165,6 +223,21 @@ class User(UserMixin):
"switches.view", "switches.create", "switches.edit", "switches.delete",
})
@property
def full_name(self):
parts = [p for p in (self.first_name, self.last_name) if p]
return " ".join(parts)
@property
def display_name(self):
return self.full_name or self.username
@property
def avatar_url(self):
if self.avatar_filename:
return url_for("static", filename=f"uploads/avatars/{self.avatar_filename}")
return None
def get_db_connection():
conn = sqlite3.connect(DB_PATH)
@@ -203,12 +276,34 @@ def _ensure_schema():
if "is_default" not in existing_cols:
conn.execute("ALTER TABLE groups ADD COLUMN is_default INTEGER DEFAULT 0")
# Migration: Vor-/Nachname für Benutzer nachrüsten.
# Migration: Vor-/Nachname + Profilbild für Benutzer nachrüsten.
user_cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)").fetchall()}
if "first_name" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN first_name TEXT")
if "last_name" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN last_name TEXT")
if "avatar_filename" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN avatar_filename TEXT")
# Änderungsverlauf (Audit-Log): wer hat wann was geändert. Bewusst ohne
# PoE-Neustarts (die stehen bereits im Live-Log von poe.sh).
conn.execute("""
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
username TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT,
details TEXT
)
""")
# "Wer hat zuletzt geändert" direkt am Objekt, zusätzlich zum Audit-Log.
device_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()}
if "last_modified_by" not in device_cols:
conn.execute("ALTER TABLE devices ADD COLUMN last_modified_by TEXT")
if "last_modified_at" not in device_cols:
conn.execute("ALTER TABLE devices ADD COLUMN last_modified_at TEXT")
# Zugangsdaten (Credentials): eigenständige, wiederverwendbare
# SSH-Logins für Switche, statt Username/Passwort direkt am Switch.
@@ -223,6 +318,10 @@ def _ensure_schema():
switch_cols = {row["name"] for row in conn.execute("PRAGMA table_info(switches)").fetchall()}
if "credential_id" not in switch_cols:
conn.execute("ALTER TABLE switches ADD COLUMN credential_id INTEGER")
if "last_modified_by" not in switch_cols:
conn.execute("ALTER TABLE switches ADD COLUMN last_modified_by TEXT")
if "last_modified_at" not in switch_cols:
conn.execute("ALTER TABLE switches ADD COLUMN last_modified_at TEXT")
# Migration: bestehende, direkt am Switch hinterlegte Zugangsdaten
# (ältere DB-Version) in eigene Credentials-Datensätze überführen.
@@ -305,7 +404,44 @@ def _build_user(row):
conn.close()
group_names = ", ".join(g["name"] for g in group_rows)
return User(row["id"], row["username"], row["is_admin"], permissions, group_names)
return User(
row["id"], row["username"], row["is_admin"], permissions, group_names,
first_name=row["first_name"] if "first_name" in row.keys() else None,
last_name=row["last_name"] if "last_name" in row.keys() else None,
avatar_filename=row["avatar_filename"] if "avatar_filename" in row.keys() else None,
)
def log_action(action, target=None, details=None):
"""Schreibt einen Eintrag ins Änderungsverlauf-Log. Absichtlich NICHT für
PoE-Neustarts genutzt (die stehen bereits im Live-Log von poe.sh)."""
who = current_user.username if current_user.is_authenticated else "system"
conn = get_db_connection()
conn.execute(
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), who, action, target, details),
)
conn.commit()
conn.close()
def touch_record(conn, table, key_col, key_val):
"""Trägt 'zuletzt geändert von/am' direkt am Datensatz ein (Devices/Switches)."""
who = current_user.username if current_user.is_authenticated else "system"
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
f"UPDATE {table} SET last_modified_by=?, last_modified_at=? WHERE {key_col}=?",
(who, now, key_val),
)
def _derive_export_fernet(passphrase: str, salt: bytes) -> Fernet:
"""Leitet aus einer Passphrase + Salt einen Fernet-Schlüssel ab (PBKDF2) —
für portable, umgebungsunabhängige Ver-/Entschlüsselung beim Import/Export
(im Gegensatz zum eigentlichen fernet.key, der pro Installation neu ist)."""
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=390000)
key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
return Fernet(key)
def encrypt_password(password: str) -> str:
@@ -370,6 +506,71 @@ def logout():
return redirect(url_for("login"))
# ---------------------------------------------------------------------------
# Eigenes Profil — jeder eingeloggte Benutzer darf Vor-/Nachname, eigenes
# Passwort und Profilbild selbst ändern (Klick auf den eigenen Namen in der
# Sidebar). Bewusst getrennt vom Admin-"Users"-Bereich, der andere Benutzer
# verwaltet.
# ---------------------------------------------------------------------------
@app.route("/profile", methods=["POST"])
@login_required
def profile():
conn = get_db_connection()
if "update_profile" in request.form:
first_name = request.form.get("first_name", "").strip() or None
last_name = request.form.get("last_name", "").strip() or None
conn.execute(
"UPDATE users SET first_name=?, last_name=? WHERE id=?",
(first_name, last_name, current_user.id),
)
conn.commit()
log_action("profile.update", current_user.username)
flash("Profil aktualisiert.", "success")
elif "change_password" in request.form:
current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
row = conn.execute("SELECT password FROM users WHERE id=?", (current_user.id,)).fetchone()
if not row or not bcrypt.check_password_hash(row["password"], current_password):
flash("Aktuelles Passwort ist falsch.", "danger")
elif not new_password or new_password != confirm_password:
flash("Neues Passwort und Bestätigung stimmen nicht überein.", "danger")
else:
pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
conn.execute("UPDATE users SET password=? WHERE id=?", (pw_hash, current_user.id))
conn.commit()
log_action("profile.password", current_user.username)
flash("Passwort geändert.", "success")
elif "upload_avatar" in request.form:
file = request.files.get("avatar")
if not file or not file.filename:
flash("Bitte ein Bild auswählen.", "danger")
else:
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
if ext not in ALLOWED_AVATAR_EXT:
flash("Nur PNG, JPG, GIF oder WEBP erlaubt.", "danger")
else:
old_row = conn.execute(
"SELECT avatar_filename FROM users WHERE id=?", (current_user.id,)
).fetchone()
filename = secure_filename(f"user_{current_user.id}_{secrets.token_hex(4)}.{ext}")
file.save(os.path.join(AVATAR_DIR, filename))
conn.execute("UPDATE users SET avatar_filename=? WHERE id=?", (filename, current_user.id))
conn.commit()
if old_row and old_row["avatar_filename"]:
old_path = os.path.join(AVATAR_DIR, old_row["avatar_filename"])
if os.path.isfile(old_path):
os.remove(old_path)
flash("Profilbild aktualisiert.", "success")
conn.close()
return redirect(request.referrer or url_for("index"))
# ---------------------------------------------------------------------------
# Log-Auswertung (Erreichbarkeits-Status je Gerät)
# ---------------------------------------------------------------------------
@@ -544,6 +745,7 @@ def settings():
new_interval = int(request.form["interval"])
set_setting("interval", new_interval)
set_setting("check_interval", new_interval * 60)
log_action("settings.update", "Prüfintervall", f"{new_interval} Minuten")
try:
subprocess.run(["systemctl", "restart", "rpi-check.service"], check=False)
@@ -556,6 +758,149 @@ def settings():
return render_template("settings.html", interval=interval)
@app.route("/settings/nav-order", methods=["POST"])
@login_required
def save_nav_order():
if not current_user.is_admin:
flash("Nur Admins dürfen die Navbar-Reihenfolge ändern!", "danger")
return redirect(url_for("index"))
order = [k for k in request.form.getlist("nav_order") if k in NAV_ITEMS_BY_KEY]
order += [k for k in DEFAULT_NAV_ORDER if k not in order]
set_setting("nav_order", json.dumps(order))
log_action("settings.update", "Navbar-Reihenfolge")
flash("Navbar-Reihenfolge gespeichert.", "success")
return redirect(url_for("settings"))
# ---------------------------------------------------------------------------
# Import/Export — für Umzug auf eine neue Umgebung. Der komplette Datensatz
# (Devices, Switche, Zugangsdaten inkl. Passwörter) wird als ein einziges,
# mit einer selbstgewählten Passphrase verschlüsseltes JSON-Bundle exportiert
# — unabhängig vom fernet.key dieser Installation, damit die Datei auf jedem
# anderen Server wieder eingelesen werden kann, der dieselbe Passphrase kennt.
# ---------------------------------------------------------------------------
@app.route("/settings/export", methods=["POST"])
@login_required
def export_data():
if not current_user.is_admin:
flash("Nur Admins dürfen Daten exportieren!", "danger")
return redirect(url_for("index"))
passphrase = request.form.get("export_passphrase", "")
if not passphrase:
flash("Bitte eine Passphrase für den Export angeben.", "danger")
return redirect(url_for("settings"))
conn = get_db_connection()
credentials_rows = conn.execute("SELECT name, username, password FROM credentials").fetchall()
switches_rows = conn.execute("""
SELECT switches.hostname, switches.ip, credentials.name AS credential_name
FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
""").fetchall()
devices_rows = conn.execute(
"SELECT mac, rpi_ip, port, name, switch_hostname, is_active FROM devices"
).fetchall()
conn.close()
payload = {
"credentials": [
{"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"])}
for r in credentials_rows
],
"switches": [
{"hostname": r["hostname"], "ip": r["ip"], "credential_name": r["credential_name"]}
for r in switches_rows
],
"devices": [dict(r) for r in devices_rows],
}
salt = secrets.token_bytes(16)
export_fernet = _derive_export_fernet(passphrase, salt)
encrypted_payload = export_fernet.encrypt(json.dumps(payload).encode("utf-8"))
envelope = {
"app": "poe-manager",
"version": 1,
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
"payload": encrypted_payload.decode("utf-8"),
}
log_action(
"data.export", "Devices/Switches/Zugangsdaten",
f"{len(payload['devices'])} Geräte, {len(payload['switches'])} Switche, {len(payload['credentials'])} Zugangsdaten",
)
filename = f"poe_manager_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
response = jsonify(envelope)
response.headers["Content-Disposition"] = f"attachment; filename={filename}"
return response
@app.route("/settings/import", methods=["POST"])
@login_required
def import_data():
if not current_user.is_admin:
flash("Nur Admins dürfen Daten importieren!", "danger")
return redirect(url_for("index"))
passphrase = request.form.get("import_passphrase", "")
upload = request.files.get("import_file")
if not passphrase or not upload or not upload.filename:
flash("Bitte Passphrase und Export-Datei angeben.", "danger")
return redirect(url_for("settings"))
try:
envelope = json.loads(upload.read().decode("utf-8"))
salt = base64.urlsafe_b64decode(envelope["salt"])
export_fernet = _derive_export_fernet(passphrase, salt)
payload = json.loads(export_fernet.decrypt(envelope["payload"].encode("utf-8")).decode("utf-8"))
except Exception:
flash("Import fehlgeschlagen: Datei ungültig oder Passphrase falsch.", "danger")
return redirect(url_for("settings"))
conn = get_db_connection()
n_cred = n_switch = n_dev = 0
for c in payload.get("credentials", []):
conn.execute(
"""INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password""",
(c["name"], c["username"], encrypt_password(c["password"])),
)
n_cred += 1
for s in payload.get("switches", []):
cred_id = None
if s.get("credential_name"):
row = conn.execute("SELECT id FROM credentials WHERE name=?", (s["credential_name"],)).fetchone()
cred_id = row["id"] if row else None
conn.execute(
"""INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?)
ON CONFLICT(hostname) DO UPDATE SET ip=excluded.ip, credential_id=excluded.credential_id""",
(s["hostname"], s["ip"], cred_id),
)
n_switch += 1
for d in payload.get("devices", []):
conn.execute(
"""INSERT INTO devices (mac, rpi_ip, port, name, switch_hostname, is_active) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(mac) DO UPDATE SET rpi_ip=excluded.rpi_ip, port=excluded.port, name=excluded.name,
switch_hostname=excluded.switch_hostname, is_active=excluded.is_active""",
(d["mac"], d["rpi_ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1)),
)
n_dev += 1
conn.commit()
conn.close()
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")
return redirect(url_for("settings"))
# ---------------------------------------------------------------------------
# Devices
# ---------------------------------------------------------------------------
@@ -601,7 +946,9 @@ def devices():
"VALUES (?, ?, ?, ?, ?, ?)",
(mac, rpi_ip, port, name, switch_hostname, is_active),
)
touch_record(conn, "devices", "mac", mac)
conn.commit()
log_action("device.create", name, f"MAC {mac}, IP {rpi_ip}")
flash(f"Gerät {name} hinzugefügt.", "success")
except sqlite3.IntegrityError:
flash("Fehler beim Hinzufügen des Geräts!", "danger")
@@ -632,7 +979,9 @@ def devices():
conn.execute(
"UPDATE devices SET switch_hostname=? WHERE mac=?", (new_switch, old_mac)
)
touch_record(conn, "devices", "mac", old_mac)
conn.commit()
log_action("device.edit", device["name"], f"Switch: {old_switch}{new_switch or 'Kein Switch'}")
flash(
f"Switch von {device['name']} geändert: {old_switch}{new_switch or 'Kein Switch'}",
"success",
@@ -661,7 +1010,9 @@ def devices():
"UPDATE devices SET mac=?, rpi_ip=?, port=?, name=? WHERE mac=?",
(mac, rpi_ip, port, name, old_mac),
)
touch_record(conn, "devices", "mac", mac)
conn.commit()
log_action("device.edit", name, f"MAC {old_mac}{mac}, IP {rpi_ip}")
flash(f"Gerät {name} aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Fehler beim Aktualisieren des Geräts!", "danger")
@@ -677,6 +1028,7 @@ def devices():
name = device["name"] if device else del_mac
conn.execute("DELETE FROM devices WHERE mac=?", (del_mac,))
conn.commit()
log_action("device.delete", name, f"MAC {del_mac}")
flash(f"Gerät {name} gelöscht.", "success")
else:
flash("Keine MAC-Adresse übermittelt!", "danger")
@@ -709,10 +1061,12 @@ def toggle_device(mac):
new_status = 0 if device["is_active"] else 1
conn.execute("UPDATE devices SET is_active=? WHERE mac=?", (new_status, mac))
touch_record(conn, "devices", "mac", mac)
conn.commit()
conn.close()
status_text = "deaktiviert" if new_status == 0 else "aktiviert"
log_action("device.deactivate" if new_status == 0 else "device.activate", device["name"])
return {"success": True, "msg": f"Gerät {device['name']} wurde {status_text}.", "new_status": new_status}
@@ -772,7 +1126,9 @@ def switches():
"INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?)",
(hostname, ip, credential_id),
)
touch_record(conn, "switches", "hostname", hostname)
conn.commit()
log_action("switch.create", hostname, f"IP {ip}")
flash(f"Switch {hostname} hinzugefügt.", "success")
except sqlite3.IntegrityError:
flash("Hostname existiert bereits oder Eingabefehler!", "danger")
@@ -798,7 +1154,9 @@ def switches():
"UPDATE devices SET switch_hostname=? WHERE switch_hostname=?",
(hostname, old_hostname),
)
touch_record(conn, "switches", "hostname", hostname)
conn.commit()
log_action("switch.edit", hostname, f"IP {ip}")
flash(f"Switch {hostname} aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Hostname existiert bereits oder Eingabefehler!", "danger")
@@ -833,6 +1191,7 @@ def delete_switch(hostname):
conn.execute("DELETE FROM switches WHERE hostname=?", (hostname,))
conn.commit()
conn.close()
log_action("switch.delete", hostname)
flash(f"Switch '{hostname}' gelöscht.", "success")
return redirect(url_for("switches"))
@@ -866,6 +1225,7 @@ def credentials():
(name, username, encrypt_password(password)),
)
conn.commit()
log_action("credential.create", name)
flash(f"Zugangsdaten '{name}' angelegt.", "success")
except sqlite3.IntegrityError:
flash("Es existieren bereits Zugangsdaten mit diesem Namen!", "danger")
@@ -893,6 +1253,7 @@ def credentials():
(name, username, cred_id),
)
conn.commit()
log_action("credential.edit", name)
flash(f"Zugangsdaten '{name}' aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Es existieren bereits Zugangsdaten mit diesem Namen!", "danger")
@@ -909,8 +1270,10 @@ def credentials():
names = ", ".join(s["hostname"] for s in used_by)
flash(f"Diese Zugangsdaten werden noch von folgenden Switchen verwendet: {names}", "danger")
else:
cred_row = conn.execute("SELECT name FROM credentials WHERE id=?", (cred_id,)).fetchone()
conn.execute("DELETE FROM credentials WHERE id=?", (cred_id,))
conn.commit()
log_action("credential.delete", cred_row["name"] if cred_row else cred_id)
flash("Zugangsdaten gelöscht.", "success")
credential_rows = conn.execute("""
@@ -1184,6 +1547,27 @@ def logs():
)
@app.route("/logs/aenderungen")
@login_required
def activity_log():
"""Änderungslog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen,
Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts — die stehen im Live-Log."""
if not current_user.is_admin:
flash("Nur Admins dürfen das Änderungslog einsehen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
entries = conn.execute(
"SELECT id, ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT 500"
).fetchall()
avatars = {
row["username"]: row["avatar_filename"]
for row in conn.execute("SELECT username, avatar_filename FROM users").fetchall()
}
conn.close()
return render_template("activity_log.html", entries=entries, avatars=avatars)
# ---------------------------------------------------------------------------
# Manueller PoE-Neustart
# ---------------------------------------------------------------------------
@@ -1255,6 +1639,7 @@ def users():
(cur.lastrowid, group_choice),
)
conn.commit()
log_action("user.create", username)
flash(f"Benutzer '{username}' erfolgreich angelegt!", "success")
except sqlite3.IntegrityError:
flash("Benutzername existiert bereits!", "danger")
@@ -1282,6 +1667,7 @@ def users():
(username, first_name, last_name, user_id),
)
conn.commit()
log_action("user.edit", username)
flash("Benutzer aktualisiert!", "success")
else:
flash("Username darf nicht leer sein!", "danger")
@@ -1291,7 +1677,7 @@ def users():
choice = request.form.get("group_id") or ""
# Mindestens ein Admin muss bestehen bleiben.
target = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
target = conn.execute("SELECT username, is_admin FROM users WHERE id=?", (user_id,)).fetchone()
if target and target["is_admin"] and choice != "admin":
admin_count = conn.execute("SELECT COUNT(*) AS n FROM users WHERE is_admin=1").fetchone()["n"]
if admin_count <= 1:
@@ -1310,13 +1696,16 @@ def users():
(user_id, choice),
)
conn.commit()
log_action("user.assign_group", target["username"] if target else user_id, choice or "keine Gruppe")
flash("Gruppe zugewiesen!", "success")
elif "delete_user" in request.form:
user_id = request.form["delete_user"]
target = conn.execute("SELECT username FROM users WHERE id=?", (user_id,)).fetchone()
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
conn.commit()
log_action("user.delete", target["username"] if target else user_id)
flash("Benutzer gelöscht!", "success")
users_list = conn.execute("""
@@ -1355,6 +1744,7 @@ def groups():
try:
conn.execute("INSERT INTO groups (name) VALUES (?)", (name,))
conn.commit()
log_action("group.create", name)
flash(f"Gruppe '{name}' angelegt.", "success")
except sqlite3.IntegrityError:
flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger")
@@ -1396,6 +1786,12 @@ def groups():
)
conn.commit()
detail_bits = []
if "permissions_submitted" in request.form:
detail_bits.append("Rechte geändert")
if "members_submitted" in request.form:
detail_bits.append("Mitglieder geändert")
log_action("group.edit", name, ", ".join(detail_bits) or None)
flash(f"Gruppe '{name}' aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger")
@@ -1418,6 +1814,7 @@ def groups():
[(uid,) for uid in selected_admins],
)
conn.commit()
log_action("group.assign_admins", "Admin", f"{len(selected_admins)} Mitglieder")
flash("Admin-Zuweisung aktualisiert.", "success")
elif "delete_group" in request.form:
@@ -1430,6 +1827,7 @@ def groups():
conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,))
conn.execute("DELETE FROM groups WHERE id=?", (group_id,))
conn.commit()
log_action("group.delete", target["name"] if target else group_id)
flash("Gruppe gelöscht.", "success")
conn.close()
+19 -1
View File
@@ -27,6 +27,8 @@ CREATE TABLE IF NOT EXISTS switches (
hostname TEXT PRIMARY KEY,
ip TEXT NOT NULL,
credential_id INTEGER,
last_modified_by TEXT,
last_modified_at TEXT,
FOREIGN KEY (credential_id) REFERENCES credentials(id)
);
""")
@@ -40,6 +42,8 @@ CREATE TABLE IF NOT EXISTS devices (
port TEXT,
name TEXT NOT NULL,
is_active INTEGER DEFAULT 1,
last_modified_by TEXT,
last_modified_at TEXT,
FOREIGN KEY (switch_hostname) REFERENCES switches(hostname)
);
""")
@@ -52,7 +56,8 @@ CREATE TABLE IF NOT EXISTS users (
password TEXT NOT NULL,
is_admin INTEGER DEFAULT 0,
first_name TEXT,
last_name TEXT
last_name TEXT,
avatar_filename TEXT
);
""")
@@ -97,6 +102,19 @@ CREATE TABLE IF NOT EXISTS settings (
);
""")
# Änderungslog (Audit-Log) — wer hat wann was geändert. Bewusst OHNE
# PoE-Neustarts (die stehen im Live-Log von poe.sh).
c.execute("""
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
username TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT,
details TEXT
);
""")
# Standard-Setting: Prüfintervall 5 Minuten (interval = Minuten, check_interval = Sekunden)
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5"))
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("check_interval", "300"))
+75
View File
@@ -168,6 +168,60 @@ button { font-family: inherit; }
margin: 10px 8px;
}
/* Nav-Gruppe mit Untermenü (z.B. "Logs" -> Live-Log / Änderungslog) */
.nav-group-toggle {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
border: none;
background: none;
color: var(--text-dim);
font-weight: 550;
font-size: 13.5px;
font-family: inherit;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.nav-group-toggle svg { width: 18px; height: 18px; flex-shrink: 0; opacity: 0.85; }
.nav-group-toggle:hover { background: var(--bg-card-hover); color: var(--text); }
.nav-group-toggle .nav-chevron { margin-left: auto; width: 14px; height: 14px; transition: transform 0.15s ease; opacity: 0.6; }
.nav-group.expanded .nav-group-toggle .nav-chevron { transform: rotate(90deg); }
.nav-group.active-group .nav-group-toggle { color: var(--accent-strong); }
.nav-group-children {
display: none;
flex-direction: column;
gap: 2px;
padding-left: 30px;
margin-top: 2px;
}
.nav-group.expanded .nav-group-children { display: flex; }
.nav-group-children .nav-item { font-size: 13px; padding: 8px 12px; }
/* Navbar-Reihenfolge (Settings) */
.nav-order-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
}
.nav-order-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-card);
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
font-size: 13.5px;
font-weight: 550;
}
.nav-order-list .nav-order-actions { display: flex; gap: 4px; }
.nav-order-list .icon-btn { width: 28px; height: 28px; }
.sidebar-footer {
padding: 14px 12px 18px;
border-top: 1px solid var(--border-soft);
@@ -421,6 +475,25 @@ button { font-family: inherit; }
.pill.disabled { background: var(--muted-dim); color: var(--muted); }
.pill.admin { background: var(--accent-dim); color: var(--accent-strong); }
.pill.user { background: var(--muted-dim); color: var(--text-dim); }
.pill.action-pill { background: var(--muted-dim); color: var(--text-dim); }
.pill.action-pill::before { display: none; }
.pill.action-pill svg { width: 13px; height: 13px; }
.avatar-sm {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
object-fit: cover;
background: var(--muted-dim);
color: var(--text-dim);
font-size: 11.5px;
font-weight: 700;
flex-shrink: 0;
}
.avatar-placeholder { text-transform: uppercase; }
.timer-pill {
display: inline-flex;
@@ -503,6 +576,7 @@ button { font-family: inherit; }
.device-card[data-status="online"]::before { background: var(--success); }
.device-card[data-status="offline"]::before { background: var(--danger); }
.device-card[data-status="unbekannt"]::before { background: var(--warning); }
.device-card[data-status="disabled"]::before { background: var(--muted); }
.device-card .dc-top {
display: flex;
@@ -521,6 +595,7 @@ button { font-family: inherit; }
}
.device-card[data-status="online"] .dc-icon { background: var(--success-dim); color: var(--success); }
.device-card[data-status="offline"] .dc-icon { background: var(--danger-dim); color: var(--danger); }
.device-card[data-status="disabled"] .dc-icon { background: var(--muted-dim); color: var(--muted); }
.device-card .dc-icon svg { width: 17px; height: 17px; }
.device-card .dc-name {
+10
View File
@@ -206,6 +206,15 @@
} catch (e) { /* noop */ }
}
/* Nav-Gruppen mit Untermenü (z.B. "Logs") auf-/zuklappen */
function initNavGroups() {
document.querySelectorAll("[data-nav-group-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {
btn.closest("[data-nav-group]").classList.toggle("expanded");
});
});
}
/* ---------------- Init ---------------- */
document.addEventListener("DOMContentLoaded", function () {
@@ -214,6 +223,7 @@
initModals();
initConfirmables();
initFlashedMessages();
initNavGroups();
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
});
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% set active_page = "logs" %}
{% block page_title %}Änderungslog{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %}
{% block content %}
{% 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",
} %}
{% 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"/>',
} %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th style="width:1%;"></th><th>Zeitpunkt</th><th>Benutzer</th><th>Aktion</th><th>Ziel</th><th>Details</th></tr></thead>
<tbody>
{% for e in entries %}
{% set kind = e['action'].split('.')[-1] %}
<tr>
<td>
{% if avatars.get(e['username']) %}
<img class="avatar-sm" src="{{ url_for('static', filename='uploads/avatars/' + avatars[e['username']]) }}" alt="">
{% else %}
<span class="avatar-sm avatar-placeholder">{{ e['username'][:1]|upper }}</span>
{% endif %}
</td>
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
<td class="cell-name">{{ e['username'] }}</td>
<td>
<span class="pill action-pill">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
{{ action_labels.get(e['action'], e['action']) }}
</span>
</td>
<td>{{ e['target'] or '—' }}</td>
<td class="text-dim">{{ e['details'] or '—' }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6">Noch keine Änderungen protokolliert.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+79 -47
View File
@@ -10,15 +10,6 @@
</head>
<body>
{% set nav_items = [
("index", "Dashboard", "grid", true),
("devices", "Devices", "cpu", true),
("switches", "Switches", "share", false),
("users", "Users", "users", false),
("logs", "Live-Log", "terminal", false),
("settings", "Settings", "sliders", false),
] %}
{% set icons = {
"grid": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
"cpu": '<rect x="6" y="6" width="12" height="12" rx="1.5"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/>',
@@ -27,6 +18,7 @@
"groups": '<rect x="3" y="4" width="8" height="7" rx="1.5"/><rect x="13" y="4" width="8" height="7" rx="1.5"/><rect x="3" y="13" width="8" height="7" rx="1.5"/><rect x="13" y="13" width="8" height="7" rx="1.5"/>',
"key": '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>',
"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"/>',
"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"/>',
} %}
@@ -45,54 +37,45 @@
</div>
<nav class="sidebar-nav">
<a href="{{ url_for('index') }}" class="nav-item {% if active_page == 'index' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['grid']|safe }}</svg>
Dashboard
{% for item in nav_items_ordered %}
{% if item.children %}
{% set child_active = item.children|selectattr('endpoint', 'equalto', request.endpoint)|list %}
<div class="nav-group {% if child_active %}expanded active-group{% endif %}" data-nav-group>
<button type="button" class="nav-group-toggle" data-nav-group-toggle>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[item.icon]|safe }}</svg>
{{ item.label }}
<svg class="nav-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</button>
<div class="nav-group-children">
{% for child in item.children %}
<a href="{{ url_for(child.endpoint) }}" class="nav-item {% if request.endpoint == child.endpoint %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[child.icon]|safe }}</svg>
{{ child.label }}
</a>
{% if current_user.can_view_devices %}
<a href="{{ url_for('devices') }}" class="nav-item {% if active_page == 'devices' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['cpu']|safe }}</svg>
Devices
</a>
{% endif %}
{% if current_user.can_manage_switches %}
<a href="{{ url_for('switches') }}" class="nav-item {% if active_page == 'switches' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['share']|safe }}</svg>
Switches
</a>
<a href="{{ url_for('credentials') }}" class="nav-item {% if active_page == 'credentials' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['key']|safe }}</svg>
Zugangsdaten
</a>
{% endif %}
{% if current_user.is_admin %}
<a href="{{ url_for('users') }}" class="nav-item {% if active_page == 'users' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['users']|safe }}</svg>
Users
</a>
<a href="{{ url_for('groups') }}" class="nav-item {% if active_page == 'groups' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['groups']|safe }}</svg>
Gruppen
</a>
<a href="{{ url_for('logs') }}" class="nav-item {% if active_page == 'logs' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['terminal']|safe }}</svg>
Live-Log
</a>
<a href="{{ url_for('settings') }}" class="nav-item {% if active_page == 'settings' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['sliders']|safe }}</svg>
Settings
{% endfor %}
</div>
</div>
{% else %}
<a href="{{ url_for(item.endpoint) }}" class="nav-item {% if active_page == item.key %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[item.icon]|safe }}</svg>
{{ item.label }}
</a>
{% endif %}
{% endfor %}
</nav>
<div class="sidebar-footer">
<div class="user-chip">
<button type="button" class="user-chip" data-open-modal="profileModal" onclick="resetProfileForms();" style="cursor:pointer; text-align:left; background:none; border:none; width:100%;">
{% if current_user.avatar_url %}
<img class="user-avatar" src="{{ current_user.avatar_url }}" alt="" style="object-fit:cover;">
{% else %}
<div class="user-avatar">{{ current_user.username[:2]|upper }}</div>
{% endif %}
<div class="user-meta">
<div class="u-name">{{ current_user.username }}</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>
</div>
</button>
<div class="footer-actions">
<button type="button" class="icon-btn" data-theme-toggle title="Theme wechseln">
<span data-theme-icon></span>
@@ -103,6 +86,49 @@
</div>
</div>
</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 %}
<div class="main {% if not current_user.is_authenticated %}no-sidebar{% endif %}">
@@ -145,6 +171,12 @@
{% endwith %}
<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 %}
</body>
</html>
+1 -1
View File
@@ -6,7 +6,7 @@
{% set can_delete = current_user.has_permission('devices.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) %}
{% block page_title %}Devices{% endblock %}
{% block page_title %}Geräte{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
{% block topbar_right %}
{% if can_create %}
+2 -2
View File
@@ -36,7 +36,7 @@
{% macro device_tile(d) %}
{% set st = status.get(d['mac'], 'unbekannt') %}
<div class="device-card {% if d['is_active'] == 0 %}is-disabled{% endif %} {% if not current_user.is_authenticated %}is-readonly{% endif %}"
data-status="{{ 'offline' if d['is_active'] == 0 else st }}"
data-status="{{ 'disabled' if d['is_active'] == 0 else st }}"
data-mac="{{ d['mac'] }}"
data-name="{{ d['name'] }}"
data-ip="{{ d['rpi_ip'] }}"
@@ -50,7 +50,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2.5"/><path d="M8 2v3M16 2v3M8 19v3M16 19v3M2 8h3M2 16h3M19 8h3M19 16h3"/></svg>
</div>
{% if d['is_active'] == 0 %}
<span class="pill disabled">Aus</span>
<span class="pill disabled">Deaktiviert</span>
{% else %}
<span class="pill {{ st }}">{{ {'online':'Online','offline':'Offline','unbekannt':'Unbekannt'}[st] }}</span>
{% endif %}
+88 -1
View File
@@ -1,10 +1,41 @@
{% extends "base.html" %}
{% set active_page = "settings" %}
{% block page_title %}Settings{% endblock %}
{% block page_title %}Einstellungen{% endblock %}
{% block page_sub %}<div class="topbar-sub">Prüfintervall für das Monitoring</div>{% endblock %}
{% block content %}
<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>
<h2 style="font-size:16px;">Prüfintervall</h2>
@@ -23,4 +54,60 @@
</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;">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 %}
+1 -1
View File
@@ -6,7 +6,7 @@
{% set can_create = current_user.has_permission('switches.create') %}
{% set can_edit = current_user.has_permission('switches.edit') %}
{% set can_delete = current_user.has_permission('switches.delete') %}
{% block page_title %}Switches{% endblock %}
{% block page_title %}Switche{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
{% block topbar_right %}
{% if can_create %}
+1 -1
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% set active_page = "users" %}
{% block page_title %}Users{% endblock %}
{% block page_title %}Benutzer{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
{% block topbar_right %}
<button type="button" class="btn btn-primary" data-open-modal="userModal" onclick="document.getElementById('userForm').reset();">