From 08d8d1f738d6f1e17cb312bfdec75c72f2c48b1a Mon Sep 17 00:00:00 2001 From: alientim Date: Mon, 10 Aug 2026 12:41:21 +0200 Subject: [PATCH] Dashboard-Kacheln, Ansichtsrechte, Standardgruppe, Countdown-Fix, Deploy-Pfad - Dashboard zeigt jetzt in beiden Zustaenden (mit/ohne Login) Kacheln statt Tabelle; ohne Login nur aktive Geraete + Online/Offline/Gesamt, eingeloggt alle Geraete + zusaetzliche Deaktiviert-Kachel - Sortierung ueberall: erst alle Nicht-Online-Geraete, dann Online, jeweils alphabetisch - Neue Rechte devices.view / switches.view; Standardgruppe 'Benutzer' wird automatisch angelegt (alle Ansichtsrechte) und jedem neuen Benutzer zugeordnet; bestehende Benutzer ohne Gruppe werden migriert - Gruppen-Seite zeigt zusaetzlich virtuelle 'Admin'-Karte (informativ) und markiert die Standardgruppe (nicht loeschbar) - 'Naechste Pruefung'-Countdown wird serverseitig aus dem tatsaechlichen letzten Log-Eintrag geseedet statt bei jedem Reload neu zu starten - Deployment-Doku korrigiert: Checkout getrennt von /srv/poe_manager, damit kein verschachteltes srv/poe_manager/srv/poe_manager entsteht Co-Authored-By: Claude Sonnet 5 --- README.md | 18 ++- srv/poe_manager/app.py | 155 ++++++++++++++++++++++---- srv/poe_manager/create_db.py | 13 ++- srv/poe_manager/static/css/style.css | 2 + srv/poe_manager/templates/base.html | 6 +- srv/poe_manager/templates/groups.html | 49 +++++++- srv/poe_manager/templates/index.html | 99 +++++----------- 7 files changed, 241 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 46a4d0b..c42db8f 100644 --- a/README.md +++ b/README.md @@ -129,15 +129,27 @@ python create_admin.py # legt ersten Admin-Benutzer an python app.py # http://localhost:5000 ``` -## Produktiv-Deployment (auf der Ziel-VM, unter `/srv/poe_manager`) +## Produktiv-Deployment (auf der Ziel-VM, App landet unter `/srv/poe_manager`) + +Das Repo wird bewusst **nicht** direkt nach `/srv/poe_manager` geklont — +`install.sh` kopiert (`rsync`) den Inhalt von `srv/poe_manager/` aus dem Repo +gezielt dorthin und lässt Quell-Checkout und Zielverzeichnis damit sauber +getrennt (kein doppeltes `/srv/poe_manager/srv/poe_manager/…`): ```bash -sudo git clone /srv/poe_manager -cd /srv/poe_manager +sudo git clone /opt/aruba-poe-modern-src +cd /opt/aruba-poe-modern-src sudo bash install.sh sudo /srv/poe_manager/venv/bin/python3 /srv/poe_manager/create_admin.py ``` +Nach der Installation liegt die laufende App vollständig unter +**`/srv/poe_manager`** (so wie es `poe_web.service`, `rpi-check.service` und +`POE_SCRIPT`/`POE_DB_PATH` als Default erwarten) — `/opt/aruba-poe-modern-src` +wird nur als Checkout-Quelle für `install.sh` gebraucht und kann danach +liegen bleiben (für spätere Updates: `git pull` dort, dann `install.sh` +erneut ausführen) oder gelöscht werden. + `install.sh` installiert Systempakete, richtet die venv ein, initialisiert die Datenbank (falls nicht vorhanden), kopiert `poe.sh` nach `/usr/local/bin/custom/`, registriert die systemd-Units diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index d223a99..d3dafe2 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -121,6 +121,7 @@ PERMISSIONS = { "devices": { "label": "Devices", "items": { + "devices.view": "Devices-Seite ansehen", "devices.toggle": "Geräte aktivieren/deaktivieren", "devices.create": "Geräte anlegen", "devices.edit": "Geräte bearbeiten (inkl. Switch-Zuordnung)", @@ -131,6 +132,7 @@ PERMISSIONS = { "switches": { "label": "Switches", "items": { + "switches.view": "Switches-Seite ansehen", "switches.create": "Switche anlegen", "switches.edit": "Switche bearbeiten", "switches.delete": "Switche löschen", @@ -138,6 +140,8 @@ PERMISSIONS = { }, } 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"] class User(UserMixin): @@ -151,9 +155,15 @@ class User(UserMixin): def has_permission(self, key): return self.is_admin or key in self.permissions + @property + def can_view_devices(self): + return self.is_admin or "devices.view" in self.permissions + @property def can_manage_switches(self): - return self.is_admin or bool(self.permissions & {"switches.create", "switches.edit", "switches.delete"}) + return self.is_admin or bool(self.permissions & { + "switches.view", "switches.create", "switches.edit", "switches.delete", + }) def get_db_connection(): @@ -164,7 +174,9 @@ def get_db_connection(): def _ensure_schema(): """Legt neue Tabellen (Gruppen/Rechte) an, falls die DB noch aus einer - älteren Version stammt — idempotent, sicher bei jedem Start aufzurufen.""" + älteren Version stammt — idempotent, sicher bei jedem Start aufzurufen. + Sorgt außerdem dafür, dass die Standardgruppe 'Benutzer' existiert und + jeder Benutzer ohne Gruppe ihr zugeordnet ist.""" conn = get_db_connection() conn.execute("CREATE TABLE IF NOT EXISTS groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL)") conn.execute(""" @@ -184,6 +196,44 @@ def _ensure_schema(): FOREIGN KEY (group_id) REFERENCES groups(id) ) """) + + # Migration: 'is_default'-Spalte nachrüsten, falls die Tabelle noch aus + # einer älteren Version ohne diese Spalte stammt. + existing_cols = {row["name"] for row in conn.execute("PRAGMA table_info(groups)").fetchall()} + if "is_default" not in existing_cols: + conn.execute("ALTER TABLE groups ADD COLUMN is_default INTEGER DEFAULT 0") + + # Standardgruppe 'Benutzer' sicherstellen. Die View-Rechte werden nur bei + # der *erstmaligen* Erzeugung gesetzt — spätere Anpassungen durch einen + # Admin (z.B. ein Recht wieder entziehen) bleiben so über Neustarts hinweg + # erhalten, statt bei jedem Start erneut hineinkopiert zu werden. + cur = conn.execute("INSERT OR IGNORE INTO groups (name, is_default) VALUES (?, 1)", (DEFAULT_GROUP_NAME,)) + if cur.rowcount > 0: + default_group_id = cur.lastrowid + conn.executemany( + "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)", + [(default_group_id, p) for p in DEFAULT_GROUP_PERMISSIONS], + ) + else: + conn.execute("UPDATE groups SET is_default=1 WHERE name=? AND is_default=0", (DEFAULT_GROUP_NAME,)) + + default_group_id = conn.execute( + "SELECT id FROM groups WHERE name=?", (DEFAULT_GROUP_NAME,) + ).fetchone()["id"] + + # Jeden nicht-admin Benutzer ohne jegliche Gruppenzugehörigkeit der + # Standardgruppe zuordnen (Migrationsfall: bestehende Benutzer sollen + # durch die Einführung des Rechtesystems keinen Zugriff verlieren). + orphan_users = conn.execute(""" + SELECT users.id FROM users + LEFT JOIN user_groups ON user_groups.user_id = users.id + WHERE users.is_admin = 0 AND user_groups.user_id IS NULL + """).fetchall() + conn.executemany( + "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", + [(u["id"], default_group_id) for u in orphan_users], + ) + conn.commit() conn.close() @@ -336,14 +386,17 @@ def get_device_status(devices): - last_checked: Zeitpunkt der letzten Prüfung (unabhängig vom Ergebnis) - last_seen: Zeitpunkt des letzten "online"-Status (nur für aktuell offline Geräte, über alle Logfiles hinweg — siehe get_last_seen) + - last_run_at: Zeitpunkt (datetime) des letzten Prüf-Durchlaufs insgesamt + (jüngste Zeile im Logfile) — Basis für den Countdown "Nächste Prüfung". """ status_dict, last_seen_dict, last_checked_dict = {}, {}, {} latest_log = _latest_log_file() if not latest_log: - return status_dict, last_seen_dict, last_checked_dict + return status_dict, last_seen_dict, last_checked_dict, None name_to_mac = {d["name"]: d["mac"] for d in devices} last_line_for_name = {} + latest_ts_str = None with open(latest_log, "r") as f: for line in f: @@ -351,6 +404,8 @@ def get_device_status(devices): if not m: continue ts_str, name, state = m.groups() + if latest_ts_str is None or ts_str > latest_ts_str: + latest_ts_str = ts_str if name in name_to_mac: last_line_for_name[name] = (ts_str, state == "erreichbar") @@ -365,7 +420,14 @@ def get_device_status(devices): else: status_dict[dev["mac"]] = "unbekannt" - return status_dict, last_seen_dict, last_checked_dict + last_run_at = None + if latest_ts_str: + try: + last_run_at = datetime.strptime(latest_ts_str, "%Y-%m-%d %H:%M:%S") + except ValueError: + last_run_at = None + + return status_dict, last_seen_dict, last_checked_dict, last_run_at # --------------------------------------------------------------------------- @@ -375,27 +437,43 @@ def get_device_status(devices): @app.route("/") def index(): """ - Dashboard. Ohne Login: schreibgeschützte Kurzübersicht (Hostname, IP, - Status, letzte Prüfung). Eingeloggt: vollständiges Dashboard mit - Statistik-Kacheln, Geräte-Details und manuellem PoE-Neustart. + Dashboard als Kachel-Ansicht. Ohne Login: nur aktive (nicht deaktivierte) + Geräte, nur Online/Offline/Gesamt-Statistik, keine Interaktion. + Eingeloggt: alle Geräte (inkl. deaktivierte), volle Statistik-Kacheln, + Klick-Details und manueller PoE-Neustart. In beiden Fällen: erst alle + Nicht-Online-Geräte (alphabetisch), danach alle Online-Geräte + (alphabetisch). """ conn = get_db_connection() - devices = conn.execute( + all_devices = conn.execute( "SELECT mac, name, rpi_ip, switch_hostname, port, is_active FROM devices ORDER BY name ASC" ).fetchall() conn.close() interval = int(get_setting("interval", 5)) - status, last_seen, last_checked = get_device_status(devices) + status, last_seen, last_checked, last_run_at = get_device_status(all_devices) - online = sum(1 for d in devices if d["is_active"] and status.get(d["mac"]) == "online") - offline = sum(1 for d in devices if d["is_active"] and status.get(d["mac"]) != "online") - disabled = sum(1 for d in devices if not d["is_active"]) - stats = {"online": online, "offline": offline, "disabled": disabled, "total": len(devices)} + is_authenticated = current_user.is_authenticated + visible_devices = all_devices if is_authenticated else [d for d in all_devices if d["is_active"]] + + def sort_key(d): + is_online = bool(d["is_active"]) and status.get(d["mac"]) == "online" + return (1 if is_online else 0, d["name"].lower()) + + devices = sorted(visible_devices, key=sort_key) + + online = sum(1 for d in visible_devices if d["is_active"] and status.get(d["mac"]) == "online") + offline = sum(1 for d in visible_devices if d["is_active"] and status.get(d["mac"]) != "online") + stats = {"online": online, "offline": offline, "total": len(visible_devices)} + if is_authenticated: + stats["disabled"] = sum(1 for d in all_devices if not d["is_active"]) + + last_run_epoch_ms = int(last_run_at.timestamp() * 1000) if last_run_at else None return render_template( "index.html", devices=devices, status=status, last_seen=last_seen, last_checked=last_checked, interval=interval, stats=stats, + last_run_epoch_ms=last_run_epoch_ms, ) @@ -435,6 +513,10 @@ def settings(): @app.route("/devices", methods=["GET", "POST"]) @login_required def devices(): + if request.method == "GET" and not current_user.can_view_devices: + flash("Keine Berechtigung, die Devices-Seite anzusehen.", "danger") + return redirect(url_for("index")) + conn = get_db_connection() switches = conn.execute("SELECT hostname FROM switches ORDER BY hostname ASC").fetchall() @@ -985,10 +1067,21 @@ def users(): if username and password: pw_hash = bcrypt.generate_password_hash(password).decode("utf-8") try: - conn.execute( + cur = conn.execute( "INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)", (username, pw_hash, is_admin), ) + # Neue, nicht-admin Benutzer landen automatisch in der + # Standardgruppe 'Benutzer' (alle Ansichtsrechte). + if not is_admin: + default_group = conn.execute( + "SELECT id FROM groups WHERE is_default=1 LIMIT 1" + ).fetchone() + if default_group: + conn.execute( + "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", + (cur.lastrowid, default_group["id"]), + ) conn.commit() flash(f"Benutzer '{username}' erfolgreich angelegt!", "success") except sqlite3.IntegrityError: @@ -1097,16 +1190,20 @@ def groups(): elif "delete_group" in request.form: group_id = request.form.get("delete_group") - conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) - conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,)) - conn.execute("DELETE FROM groups WHERE id=?", (group_id,)) - conn.commit() - flash("Gruppe gelöscht.", "success") + target = conn.execute("SELECT name, is_default FROM groups WHERE id=?", (group_id,)).fetchone() + if target and target["is_default"]: + flash(f"Die Standardgruppe '{target['name']}' kann nicht gelöscht werden.", "danger") + else: + conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) + conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,)) + conn.execute("DELETE FROM groups WHERE id=?", (group_id,)) + conn.commit() + flash("Gruppe gelöscht.", "success") conn.close() return redirect(url_for("groups")) - group_rows = conn.execute("SELECT id, name FROM groups ORDER BY name ASC").fetchall() + group_rows = conn.execute("SELECT id, name, is_default FROM groups ORDER BY is_default DESC, name ASC").fetchall() all_users = conn.execute("SELECT id, username FROM users WHERE is_admin=0 ORDER BY username ASC").fetchall() groups_data = [] @@ -1123,14 +1220,30 @@ def groups(): groups_data.append({ "id": g["id"], "name": g["name"], + "is_default": bool(g["is_default"]), "permissions": {p["permission"] for p in perm_rows}, "members": {m["id"] for m in member_rows}, "member_names": [m["username"] for m in member_rows], }) + # Virtuelle "Admin"-Gruppe: rein informativ, damit auf einen Blick + # sichtbar ist, wer alles darf — Mitgliedschaft/Rechte werden weiterhin + # ausschließlich über den is_admin-Schalter auf der Users-Seite gesteuert, + # hier gibt es daher bewusst kein Formular. + admin_rows = conn.execute("SELECT username FROM users WHERE is_admin=1 ORDER BY username ASC").fetchall() + admin_virtual_group = { + "name": "Admin", + "permissions": set(ALL_PERMISSION_KEYS), + "member_names": [u["username"] for u in admin_rows], + } + conn.close() return render_template( - "groups.html", groups=groups_data, all_users=all_users, permission_catalog=PERMISSIONS + "groups.html", + groups=groups_data, + admin_virtual_group=admin_virtual_group, + all_users=all_users, + permission_catalog=PERMISSIONS, ) diff --git a/srv/poe_manager/create_db.py b/srv/poe_manager/create_db.py index 0a68cc1..69236d9 100644 --- a/srv/poe_manager/create_db.py +++ b/srv/poe_manager/create_db.py @@ -48,7 +48,8 @@ CREATE TABLE IF NOT EXISTS users ( c.execute(""" CREATE TABLE IF NOT EXISTS groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL + name TEXT UNIQUE NOT NULL, + is_default INTEGER DEFAULT 0 ); """) @@ -86,6 +87,16 @@ CREATE TABLE IF NOT EXISTS settings ( 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")) +# Standardgruppe 'Benutzer' mit allen Ansichtsrechten — jeder neu angelegte +# (nicht-admin) Benutzer landet automatisch darin (siehe app.py: users()). +cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default) VALUES (?, 1)", ("Benutzer",)) +if cur.rowcount > 0: + default_group_id = cur.lastrowid + c.executemany( + "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)", + [(default_group_id, "devices.view"), (default_group_id, "switches.view")], + ) + conn.commit() conn.close() print(f"Datenbank '{DB_PATH}' wurde initialisiert inklusive Settings.") diff --git a/srv/poe_manager/static/css/style.css b/srv/poe_manager/static/css/style.css index d0067f2..7c57997 100644 --- a/srv/poe_manager/static/css/style.css +++ b/srv/poe_manager/static/css/style.css @@ -462,6 +462,8 @@ button { font-family: inherit; } .device-card:active { transform: translateY(0); } .device-card.is-disabled { cursor: default; opacity: 0.65; } .device-card.is-disabled:hover { transform: none; border-color: var(--border-soft); background: var(--bg-card); } +.device-card.is-readonly { cursor: default; } +.device-card.is-readonly:hover { transform: none; border-color: var(--border-soft); background: var(--bg-card); } .device-card::before { content: ""; diff --git a/srv/poe_manager/templates/base.html b/srv/poe_manager/templates/base.html index d4ac470..6b61941 100644 --- a/srv/poe_manager/templates/base.html +++ b/srv/poe_manager/templates/base.html @@ -48,15 +48,19 @@ {{ icons['grid']|safe }} Dashboard + {% if current_user.can_view_devices %} {{ icons['cpu']|safe }} Devices - {% if current_user.is_admin %} + {% endif %} + {% if current_user.can_manage_switches %} {{ icons['share']|safe }} Switches + {% endif %} + {% if current_user.is_admin %} {{ icons['users']|safe }} Users diff --git a/srv/poe_manager/templates/groups.html b/srv/poe_manager/templates/groups.html index 0f68b6b..b59db28 100644 --- a/srv/poe_manager/templates/groups.html +++ b/srv/poe_manager/templates/groups.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% set active_page = "groups" %} {% block page_title %}Gruppen{% endblock %} -{% block page_sub %}
{{ groups|length }} Gruppen · Rechteverwaltung
{% endblock %} +{% block page_sub %}
{{ groups|length + 1 }} Gruppen · Rechteverwaltung
{% endblock %} {% block topbar_right %}
+ {% if g.is_default %} +

+ Standardgruppe — jeder neu angelegte Benutzer wird ihr automatisch zugeordnet. Kann nicht gelöscht werden. +

+ {% endif %}
{% for cat_key, cat in permission_catalog.items() %} @@ -71,10 +114,12 @@ {% if g.member_names %}Mitglieder: {{ g.member_names|join(', ') }}{% else %}Keine Mitglieder{% endif %} + {% if not g.is_default %}
+ {% endif %}
{% endfor %} diff --git a/srv/poe_manager/templates/index.html b/srv/poe_manager/templates/index.html index dacd997..294ca9e 100644 --- a/srv/poe_manager/templates/index.html +++ b/srv/poe_manager/templates/index.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% set active_page = "index" %} {% block page_title %}Dashboard{% endblock %} -{% block page_sub %}
{{ devices|length }} Geräte im Bestand
{% endblock %} +{% block page_sub %}
{{ devices|length }} Geräte{{ " im Bestand" if current_user.is_authenticated else "" }}
{% endblock %} {% block topbar_right %} Nächste Prüfung in --s {% endblock %} @@ -17,62 +17,23 @@
Offline
{{ stats.offline }}
+ {% if current_user.is_authenticated %}
Deaktiviert
{{ stats.disabled }}
+ {% endif %}
Gesamt
{{ stats.total }}
-{% if not current_user.is_authenticated %} -{# ============================================================ #} -{# Öffentliche, schreibgeschützte Kurzübersicht (kein Login) #} -{# ============================================================ #} -
-
- - - - - - {% for d in devices %} - {% set st = status.get(d['mac'], 'unbekannt') %} - - - - - - - {% else %} - - {% endfor %} - -
HostnameIP-AdresseStatusLetzte Prüfung
{{ d['name'] }}{{ d['rpi_ip'] }} - {% if d['is_active'] == 0 %} - Deaktiviert - {% else %} - {{ {'online':'Online','offline':'Offline','unbekannt':'Unbekannt'}[st] }} - {% endif %} - {{ last_checked.get(d['mac']) or '—' }}
Noch keine Geräte vorhanden.
-
-
-

- Nur Lese-Ansicht. Anmelden, - um Geräte zu verwalten oder einen PoE-Neustart auszulösen. -

- -{% else %} -{# ============================================================ #} -{# Vollständiges Dashboard für eingeloggte Benutzer #} -{# ============================================================ #} {% if devices %}
{% for d in devices %} {% set st = status.get(d['mac'], 'unbekannt') %} -
{% else %}
+ {% if current_user.is_authenticated %} Noch keine Geräte angelegt. Füge welche unter Devices hinzu. + {% else %} + Aktuell keine Geräte verfügbar. + {% endif %}
{% endif %} +{% if not current_user.is_authenticated %} +

+ Nur Lese-Ansicht — deaktivierte Geräte werden nicht angezeigt. + Anmelden, + um alle Geräte zu verwalten oder einen PoE-Neustart auszulösen. +

+{% endif %} + +{% if current_user.is_authenticated %}