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 <noreply@anthropic.com>
This commit is contained in:
+134
-21
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user