diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index 8fe4bed..dff47dc 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -84,6 +84,21 @@ AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars") # explizit eingetragen werden muss. SWITCH_DEFAULT_SSH_PORT = 22 ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"} + +# Zugangsdaten-Kategorien — legt der Admin explizit fest (siehe +# _ensure_schema()-Kommentar für die Begründung gegen automatische +# Ping-basierte Erkennung). Nur "linux" ist aktuell für eine Aktion nutzbar +# (SSH-Update unter "Wartung"); die anderen sind reine Einordnung, z.B. für +# spätere Erweiterungen (Windows/PowerShell folgt separat). +CREDENTIAL_CATEGORIES = [ + ("switch", "Switch"), + ("linux", "Linux-Client"), + ("windows", "Windows-Client"), + ("router", "Router/Sonstiges"), +] +CREDENTIAL_CATEGORY_KEYS = {c[0] for c in CREDENTIAL_CATEGORIES} +# Kategorie, für die die SSH-Update-Aktion unter "Wartung" angeboten wird. +DEVICE_MAINTENANCE_CATEGORY = "linux" os.makedirs(AVATAR_DIR, exist_ok=True) app = Flask(__name__) @@ -661,9 +676,20 @@ def _ensure_schema(): id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, username TEXT NOT NULL, - password TEXT NOT NULL + password TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'switch' ) """) + # Kategorie (Switch/Linux-Client/Windows-Client/Router-Sonstiges) — + # bestimmt u.a., ob für ein Gerät mit diesen Zugangsdaten die + # SSH-Update-Aktion unter "Wartung" angeboten wird (siehe + # DEVICE_MAINTENANCE_CATEGORY). Bewusst KEINE automatische Erkennung + # per Ping o.ä. — ein reiner TTL-Hinweis ist keine verlässliche + # Grundlage dafür, unbeaufsichtigt Systembefehle auszuführen; der Admin + # legt die Kategorie explizit fest. + credential_cols = {row["name"] for row in conn.execute("PRAGMA table_info(credentials)").fetchall()} + if "category" not in credential_cols: + conn.execute("ALTER TABLE credentials ADD COLUMN category TEXT NOT NULL DEFAULT 'switch'") 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") @@ -676,6 +702,15 @@ def _ensure_schema(): if "ssh_port" not in switch_cols: conn.execute("ALTER TABLE switches ADD COLUMN ssh_port INTEGER") + # Optionale SSH-Anbindung eines Clients (devices) — analog zu Switchen, + # damit z.B. ein Linux-Client für die Wartungs-/Update-Aktion erreichbar + # ist. NULL/kein credential_id = kein SSH-Zugriff für dieses Gerät. + device_ssh_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()} + if "credential_id" not in device_ssh_cols: + conn.execute("ALTER TABLE devices ADD COLUMN credential_id INTEGER") + if "ssh_port" not in device_ssh_cols: + conn.execute("ALTER TABLE devices ADD COLUMN ssh_port INTEGER") + # DHCP: eigene Options-Definitionen + Werte (global/per-Client, # siehe create_db.py für die ausführliche Begründung von device_mac=''). conn.execute(""" @@ -2874,11 +2909,25 @@ def devices(): flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger") return redirect(url_for("devices")) + # SSH-Anbindung optional — die meisten Geräte sind keine per SSH + # erreichbaren Hosts, nur wer bewusst Zugangsdaten auswählt/anlegt + # bekommt sie zugeordnet (siehe Wartungsseite). + ssh_port, port_error = _parse_ssh_port(request.form) + credential_id, cred_error = (None, None) + if request.form.get("credential_choice"): + credential_id, cred_error = _resolve_credential_choice(conn, default_category="linux") + if port_error: + flash(port_error, "danger") + return redirect(url_for("devices")) + if cred_error: + flash(cred_error, "danger") + return redirect(url_for("devices")) + try: conn.execute( - "INSERT INTO devices (mac, rpi_ip, port, name, switch_hostname, is_active) " - "VALUES (?, ?, ?, ?, ?, ?)", - (mac, rpi_ip, port, name, switch_hostname, is_active), + "INSERT INTO devices (mac, rpi_ip, port, name, switch_hostname, is_active, credential_id, ssh_port) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (mac, rpi_ip, port, name, switch_hostname, is_active, credential_id, ssh_port), ) touch_record(conn, "devices", "mac", mac) conn.commit() @@ -2939,10 +2988,21 @@ def devices(): flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger") return redirect(url_for("devices")) + ssh_port, port_error = _parse_ssh_port(request.form) + credential_id, cred_error = (None, None) + if request.form.get("credential_choice"): + credential_id, cred_error = _resolve_credential_choice(conn, default_category="linux") + if port_error: + flash(port_error, "danger") + return redirect(url_for("devices")) + if cred_error: + flash(cred_error, "danger") + return redirect(url_for("devices")) + try: conn.execute( - "UPDATE devices SET mac=?, rpi_ip=?, port=?, name=? WHERE mac=?", - (mac, rpi_ip, port, name, old_mac), + "UPDATE devices SET mac=?, rpi_ip=?, port=?, name=?, credential_id=?, ssh_port=? WHERE mac=?", + (mac, rpi_ip, port, name, credential_id, ssh_port, old_mac), ) touch_record(conn, "devices", "mac", mac) conn.commit() @@ -2973,13 +3033,15 @@ def devices(): device_rows = conn.execute(""" SELECT devices.mac, devices.rpi_ip, devices.port, devices.name, devices.is_active, + devices.credential_id, devices.ssh_port, switches.hostname AS switch_hostname FROM devices LEFT JOIN switches ON devices.switch_hostname = switches.hostname ORDER BY switches.hostname ASC, devices.name ASC """).fetchall() + all_credentials = conn.execute("SELECT id, name, username, category FROM credentials ORDER BY name ASC").fetchall() conn.close() - return render_template("devices.html", devices=device_rows, switches=switches) + return render_template("devices.html", devices=device_rows, switches=switches, all_credentials=all_credentials) @app.route("/devices/toggle/", methods=["POST"]) @@ -3011,13 +3073,16 @@ def toggle_device(mac): # Switches # --------------------------------------------------------------------------- -def _resolve_credential_choice(conn): +def _resolve_credential_choice(conn, default_category="switch"): """ - Liest die Zugangsdaten-Auswahl aus einem Switch-Formular: entweder eine - bestehende credential_id, oder (choice == "new") legt direkt aus dem - Switch-Formular heraus neue Zugangsdaten an — damit man beim Anlegen - eines Switches nicht zuerst zu "Zugangsdaten" wechseln muss. - Gibt (credential_id, error_message) zurück; error_message ist None bei Erfolg. + Liest die Zugangsdaten-Auswahl aus einem Switch-/Client-Formular: + entweder eine bestehende credential_id, oder (choice == "new") legt + direkt aus dem Formular heraus neue Zugangsdaten an — damit man beim + Anlegen nicht zuerst zu "Zugangsdaten" wechseln muss. default_category + wird nur für neu angelegte Zugangsdaten verwendet (z.B. "linux", wenn + aus dem Client-Formular heraus angelegt), bestehende behalten ihre + eigene Kategorie. Gibt (credential_id, error_message) zurück; + error_message ist None bei Erfolg. """ choice = request.form.get("credential_choice", "") if choice == "new": @@ -3030,8 +3095,8 @@ def _resolve_credential_choice(conn): return None, "Für neue Zugangsdaten müssen Name, Username und Passwort ausgefüllt sein!" try: cur = conn.execute( - "INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)", - (name, username, encrypt_password(password)), + "INSERT INTO credentials (name, username, password, category) VALUES (?, ?, ?, ?)", + (name, username, encrypt_password(password), default_category), ) return cur.lastrowid, None except sqlite3.IntegrityError: @@ -3176,11 +3241,14 @@ def credentials(): name = request.form.get("name", "").strip() username = request.form.get("username", "").strip() password = request.form.get("password", "") + category = request.form.get("category", "").strip() + if category not in CREDENTIAL_CATEGORY_KEYS: + category = "switch" if name and username and password: try: conn.execute( - "INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)", - (name, username, encrypt_password(password)), + "INSERT INTO credentials (name, username, password, category) VALUES (?, ?, ?, ?)", + (name, username, encrypt_password(password), category), ) conn.commit() log_action("credential.create", name) @@ -3198,17 +3266,20 @@ def credentials(): name = request.form.get("name", "").strip() username = request.form.get("username", "").strip() new_password = request.form.get("password", "") + category = request.form.get("category", "").strip() + if category not in CREDENTIAL_CATEGORY_KEYS: + category = "switch" if name and username: try: if new_password: conn.execute( - "UPDATE credentials SET name=?, username=?, password=? WHERE id=?", - (name, username, encrypt_password(new_password), cred_id), + "UPDATE credentials SET name=?, username=?, password=?, category=? WHERE id=?", + (name, username, encrypt_password(new_password), category, cred_id), ) else: conn.execute( - "UPDATE credentials SET name=?, username=? WHERE id=?", - (name, username, cred_id), + "UPDATE credentials SET name=?, username=?, category=? WHERE id=?", + (name, username, category, cred_id), ) conn.commit() log_action("credential.edit", name) @@ -3224,10 +3295,11 @@ def credentials(): flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger") return redirect(url_for("credentials")) cred_id = request.form.get("delete_credential") - used_by = conn.execute("SELECT hostname FROM switches WHERE credential_id=?", (cred_id,)).fetchall() - if used_by: - names = ", ".join(s["hostname"] for s in used_by) - flash(f"Diese Zugangsdaten werden noch von folgenden Switchen verwendet: {names}", "danger") + used_by_switches = conn.execute("SELECT hostname FROM switches WHERE credential_id=?", (cred_id,)).fetchall() + used_by_devices = conn.execute("SELECT name FROM devices WHERE credential_id=?", (cred_id,)).fetchall() + if used_by_switches or used_by_devices: + names = ", ".join([s["hostname"] for s in used_by_switches] + [d["name"] for d in used_by_devices]) + flash(f"Diese Zugangsdaten werden noch verwendet von: {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,)) @@ -3236,15 +3308,17 @@ def credentials(): flash("Zugangsdaten gelöscht.", "success") credential_rows = conn.execute(""" - SELECT credentials.id, credentials.name, credentials.username, - COUNT(switches.hostname) AS switch_count + SELECT credentials.id, credentials.name, credentials.username, credentials.category, + COUNT(DISTINCT switches.hostname) AS switch_count, + COUNT(DISTINCT devices.mac) AS device_count FROM credentials LEFT JOIN switches ON switches.credential_id = credentials.id + LEFT JOIN devices ON devices.credential_id = credentials.id GROUP BY credentials.id ORDER BY credentials.name ASC """).fetchall() conn.close() - return render_template("credentials.html", credentials=credential_rows) + return render_template("credentials.html", credentials=credential_rows, categories=CREDENTIAL_CATEGORIES) # --------------------------------------------------------------------------- @@ -3629,7 +3703,9 @@ def restart_device(mac): return jsonify({"success": False, "message": "Keine Berechtigung für den PoE-Neustart."}), 403 conn = get_db_connection() - device = conn.execute("SELECT mac, name, is_active FROM devices WHERE mac=?", (mac,)).fetchone() + device = conn.execute( + "SELECT mac, name, is_active, switch_hostname, port FROM devices WHERE mac=?", (mac,) + ).fetchone() conn.close() if not device: @@ -3638,6 +3714,12 @@ def restart_device(mac): if device["is_active"] == 0: return jsonify({"success": False, "message": f"{device['name']} ist deaktiviert."}), 400 + # Ohne zugewiesenen Switch + Port kann poe.sh keinen PoE-Reset auslösen + # (siehe poe.sh: der SSH-Restart-Block wird dort übersprungen) — dann + # lieber gar nicht erst einen "Neustart gestartet"-Erfolg vorgaukeln. + if not device["switch_hostname"] or not device["port"]: + return jsonify({"success": False, "message": f"{device['name']} hat keinen Switch/Port zugewiesen — PoE-Neustart nicht möglich."}), 400 + try: subprocess.Popen( ["/bin/bash", POE_SCRIPT, "restart", mac], diff --git a/srv/poe_manager/static/css/style.css b/srv/poe_manager/static/css/style.css index b43bf30..b882710 100644 --- a/srv/poe_manager/static/css/style.css +++ b/srv/poe_manager/static/css/style.css @@ -409,7 +409,14 @@ button { font-family: inherit; } .btn-sm { padding: 6px 12px; font-size: 12.5px; border-radius: 8px; } .btn-block { width: 100%; } -.btn:disabled { opacity: 0.55; cursor: not-allowed; } +.btn:disabled { + opacity: 0.65; + cursor: not-allowed; + background: var(--bg-card-hover) !important; + color: var(--text-dim) !important; + border-color: var(--border) !important; + box-shadow: none !important; +} .icon-btn { display: inline-flex; align-items: center; justify-content: center; diff --git a/srv/poe_manager/templates/credentials.html b/srv/poe_manager/templates/credentials.html index 0b13688..a49f7c8 100644 --- a/srv/poe_manager/templates/credentials.html +++ b/srv/poe_manager/templates/credentials.html @@ -3,6 +3,7 @@ {% set can_create = current_user.has_permission('credentials.create') %} {% set can_edit = current_user.has_permission('credentials.edit') %} {% set can_delete = current_user.has_permission('credentials.edit') %} +{% set category_labels = dict(categories) %} {% block page_title %}Zugangsdaten{% endblock %} {% block page_sub %}
{{ credentials|length }} Zugangsdaten
{% endblock %} @@ -27,20 +28,29 @@ Name Username + Kategorie Verwendet von Aktionen {% for c in credentials %} - + {% set usage_total = c['switch_count'] + c['device_count'] %} + {{ c['name'] }} {{ c['username'] }} - {{ c['switch_count'] }} Switch{{ 'e' if c['switch_count'] != 1 else '' }} + {{ category_labels.get(c['category'], c['category']) }} + + {% if usage_total == 0 %}—{% else %} + {% if c['switch_count'] %}{{ c['switch_count'] }} Switch{{ 'e' if c['switch_count'] != 1 else '' }}{% endif %} + {% if c['switch_count'] and c['device_count'] %}, {% endif %} + {% if c['device_count'] %}{{ c['device_count'] }} Gerät{{ 'e' if c['device_count'] != 1 else '' }}{% endif %} + {% endif %} +
{% if can_edit %} {% endif %} @@ -56,7 +66,7 @@ {% else %} - Noch keine Zugangsdaten vorhanden. + Noch keine Zugangsdaten vorhanden. {% endfor %} @@ -80,6 +90,14 @@
+
+ +
Bestimmt u.a., ob diese Zugangsdaten unter „Wartung“ für Bulk-Updates nutzbar sind.
+
@@ -115,6 +133,13 @@
+
+ +
@@ -136,10 +161,11 @@ {% block scripts %} +