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:
@@ -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/
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>×</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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,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();">
|
||||
|
||||
Reference in New Issue
Block a user