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 @@
Dashboard
+ {% if current_user.can_view_devices %}
Devices
- {% if current_user.is_admin %}
+ {% endif %}
+ {% if current_user.can_manage_switches %}
Switches
+ {% endif %}
+ {% if current_user.is_admin %}
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 %}
+{# Virtuelle Admin-"Gruppe": rein informativ, kein Formular — Admin-Status
+ wird ausschließlich über den is_admin-Schalter auf der Users-Seite gesetzt. #}
+
+
+
+
Admin
+ Systemrolle
+
+
+
+ Admins dürfen immer alles — Rolle wird über Users vergeben, nicht hier.
+
+
+ {% for cat_key, cat in permission_catalog.items() %}
+
+
{{ cat['label'] }}
+
+ {% for key, label in cat['items'].items() %}
+
+ {% endfor %}
+
{% 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 %}
@@ -159,7 +133,12 @@ document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ interval | int }};
const intervalMilliseconds = intervalMinutes * 60 * 1000;
const isAuthenticated = {{ "true" if current_user.is_authenticated else "false" }};
- let lastUpdateTime = Date.now();
+ // Vom Server ermittelter Zeitpunkt des letzten echten Prüf-Durchlaufs
+ // (aus dem Logfile) — damit startet der Countdown nicht bei jedem
+ // Seitenaufruf wieder von vorn, sondern zeigt die tatsächlich
+ // verbleibende Zeit bis zur nächsten Prüfung durch poe.sh.
+ const lastRunAt = {{ last_run_epoch_ms | tojson }};
+ let lastUpdateTime = lastRunAt || Date.now();
function updateTimer() {
const now = Date.now();
@@ -171,38 +150,15 @@ document.addEventListener("DOMContentLoaded", () => {
if (remainingSec <= 1) window.location.reload();
}
+ setInterval(updateTimer, 1000);
+ updateTimer();
+
if (!isAuthenticated) {
- // Öffentliche Ansicht: einfacher Reload-Timer, kein Zugriff auf /get_log nötig.
- setInterval(updateTimer, 1000);
return;
}
let selectedCard = null, selectedMac = null, selectedName = null;
- function parseLogTimestamp(ts) {
- const parts = ts.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
- if (!parts) return Date.now();
- const [, year, month, day, hour, minute, second] = parts.map(Number);
- return new Date(year, month - 1, day, hour, minute, second).getTime();
- }
-
- function fetchLastLog() {
- fetch("{{ url_for('get_log') }}")
- .then(r => r.text())
- .then(data => {
- const lines = data.split("\n");
- let lastSepIndex = -1;
- for (let i = lines.length - 1; i >= 0; i--) {
- if (lines[i].startsWith("----")) { lastSepIndex = i; break; }
- }
- if (lastSepIndex >= 0 && lastSepIndex + 1 < lines.length) {
- const match = lines[lastSepIndex + 1].match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
- if (match) lastUpdateTime = parseLogTimestamp(match[1]);
- }
- })
- .catch(() => {});
- }
-
document.querySelectorAll(".device-card").forEach(card => {
const mac = card.dataset.mac;
const restartKey = "restart_" + mac;
@@ -263,9 +219,6 @@ document.addEventListener("DOMContentLoaded", () => {
.catch(err => { button.disabled = false; showToast("Fehler beim Starten des Neustarts.", "danger"); });
});
}
-
- setInterval(updateTimer, 1000);
- fetchLastLog();
});
{% endblock %}