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:
2026-08-10 12:41:21 +02:00
co-authored by Claude Sonnet 5
parent 82bfeb17ed
commit 08d8d1f738
7 changed files with 241 additions and 101 deletions
+15 -3
View File
@@ -129,15 +129,27 @@ python create_admin.py # legt ersten Admin-Benutzer an
python app.py # http://localhost:5000 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 ```bash
sudo git clone <dieses-repo> /srv/poe_manager sudo git clone <dieses-repo> /opt/aruba-poe-modern-src
cd /srv/poe_manager cd /opt/aruba-poe-modern-src
sudo bash install.sh sudo bash install.sh
sudo /srv/poe_manager/venv/bin/python3 /srv/poe_manager/create_admin.py 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 `install.sh` installiert Systempakete, richtet die venv ein, initialisiert die
Datenbank (falls nicht vorhanden), kopiert `poe.sh` nach Datenbank (falls nicht vorhanden), kopiert `poe.sh` nach
`/usr/local/bin/custom/`, registriert die systemd-Units `/usr/local/bin/custom/`, registriert die systemd-Units
+129 -16
View File
@@ -121,6 +121,7 @@ PERMISSIONS = {
"devices": { "devices": {
"label": "Devices", "label": "Devices",
"items": { "items": {
"devices.view": "Devices-Seite ansehen",
"devices.toggle": "Geräte aktivieren/deaktivieren", "devices.toggle": "Geräte aktivieren/deaktivieren",
"devices.create": "Geräte anlegen", "devices.create": "Geräte anlegen",
"devices.edit": "Geräte bearbeiten (inkl. Switch-Zuordnung)", "devices.edit": "Geräte bearbeiten (inkl. Switch-Zuordnung)",
@@ -131,6 +132,7 @@ PERMISSIONS = {
"switches": { "switches": {
"label": "Switches", "label": "Switches",
"items": { "items": {
"switches.view": "Switches-Seite ansehen",
"switches.create": "Switche anlegen", "switches.create": "Switche anlegen",
"switches.edit": "Switche bearbeiten", "switches.edit": "Switche bearbeiten",
"switches.delete": "Switche löschen", "switches.delete": "Switche löschen",
@@ -138,6 +140,8 @@ PERMISSIONS = {
}, },
} }
ALL_PERMISSION_KEYS = [key for cat in PERMISSIONS.values() for key in cat["items"]] 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): class User(UserMixin):
@@ -151,9 +155,15 @@ class User(UserMixin):
def has_permission(self, key): def has_permission(self, key):
return self.is_admin or key in self.permissions 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 @property
def can_manage_switches(self): 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(): def get_db_connection():
@@ -164,7 +174,9 @@ def get_db_connection():
def _ensure_schema(): def _ensure_schema():
"""Legt neue Tabellen (Gruppen/Rechte) an, falls die DB noch aus einer """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 = get_db_connection()
conn.execute("CREATE TABLE IF NOT EXISTS groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL)") conn.execute("CREATE TABLE IF NOT EXISTS groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL)")
conn.execute(""" conn.execute("""
@@ -184,6 +196,44 @@ def _ensure_schema():
FOREIGN KEY (group_id) REFERENCES groups(id) 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.commit()
conn.close() conn.close()
@@ -336,14 +386,17 @@ def get_device_status(devices):
- last_checked: Zeitpunkt der letzten Prüfung (unabhängig vom Ergebnis) - 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, - last_seen: Zeitpunkt des letzten "online"-Status (nur für aktuell offline Geräte,
über alle Logfiles hinweg — siehe get_last_seen) ü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 = {}, {}, {} status_dict, last_seen_dict, last_checked_dict = {}, {}, {}
latest_log = _latest_log_file() latest_log = _latest_log_file()
if not latest_log: 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} name_to_mac = {d["name"]: d["mac"] for d in devices}
last_line_for_name = {} last_line_for_name = {}
latest_ts_str = None
with open(latest_log, "r") as f: with open(latest_log, "r") as f:
for line in f: for line in f:
@@ -351,6 +404,8 @@ def get_device_status(devices):
if not m: if not m:
continue continue
ts_str, name, state = m.groups() 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: if name in name_to_mac:
last_line_for_name[name] = (ts_str, state == "erreichbar") last_line_for_name[name] = (ts_str, state == "erreichbar")
@@ -365,7 +420,14 @@ def get_device_status(devices):
else: else:
status_dict[dev["mac"]] = "unbekannt" 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("/") @app.route("/")
def index(): def index():
""" """
Dashboard. Ohne Login: schreibgeschützte Kurzübersicht (Hostname, IP, Dashboard als Kachel-Ansicht. Ohne Login: nur aktive (nicht deaktivierte)
Status, letzte Prüfung). Eingeloggt: vollständiges Dashboard mit Geräte, nur Online/Offline/Gesamt-Statistik, keine Interaktion.
Statistik-Kacheln, Geräte-Details und manuellem PoE-Neustart. 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() 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" "SELECT mac, name, rpi_ip, switch_hostname, port, is_active FROM devices ORDER BY name ASC"
).fetchall() ).fetchall()
conn.close() conn.close()
interval = int(get_setting("interval", 5)) 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") is_authenticated = current_user.is_authenticated
offline = sum(1 for d in devices if d["is_active"] and status.get(d["mac"]) != "online") visible_devices = all_devices if is_authenticated else [d for d in all_devices if d["is_active"]]
disabled = sum(1 for d in devices if not d["is_active"])
stats = {"online": online, "offline": offline, "disabled": disabled, "total": len(devices)} 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( return render_template(
"index.html", devices=devices, status=status, last_seen=last_seen, "index.html", devices=devices, status=status, last_seen=last_seen,
last_checked=last_checked, interval=interval, stats=stats, 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"]) @app.route("/devices", methods=["GET", "POST"])
@login_required @login_required
def devices(): 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() conn = get_db_connection()
switches = conn.execute("SELECT hostname FROM switches ORDER BY hostname ASC").fetchall() switches = conn.execute("SELECT hostname FROM switches ORDER BY hostname ASC").fetchall()
@@ -985,10 +1067,21 @@ def users():
if username and password: if username and password:
pw_hash = bcrypt.generate_password_hash(password).decode("utf-8") pw_hash = bcrypt.generate_password_hash(password).decode("utf-8")
try: try:
conn.execute( cur = conn.execute(
"INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)", "INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)",
(username, pw_hash, is_admin), (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() conn.commit()
flash(f"Benutzer '{username}' erfolgreich angelegt!", "success") flash(f"Benutzer '{username}' erfolgreich angelegt!", "success")
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
@@ -1097,6 +1190,10 @@ def groups():
elif "delete_group" in request.form: elif "delete_group" in request.form:
group_id = request.form.get("delete_group") group_id = request.form.get("delete_group")
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 group_permissions WHERE group_id=?", (group_id,))
conn.execute("DELETE FROM user_groups 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.execute("DELETE FROM groups WHERE id=?", (group_id,))
@@ -1106,7 +1203,7 @@ def groups():
conn.close() conn.close()
return redirect(url_for("groups")) 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() all_users = conn.execute("SELECT id, username FROM users WHERE is_admin=0 ORDER BY username ASC").fetchall()
groups_data = [] groups_data = []
@@ -1123,14 +1220,30 @@ def groups():
groups_data.append({ groups_data.append({
"id": g["id"], "id": g["id"],
"name": g["name"], "name": g["name"],
"is_default": bool(g["is_default"]),
"permissions": {p["permission"] for p in perm_rows}, "permissions": {p["permission"] for p in perm_rows},
"members": {m["id"] for m in member_rows}, "members": {m["id"] for m in member_rows},
"member_names": [m["username"] 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() conn.close()
return render_template( 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,
) )
+12 -1
View File
@@ -48,7 +48,8 @@ CREATE TABLE IF NOT EXISTS users (
c.execute(""" c.execute("""
CREATE TABLE IF NOT EXISTS groups ( CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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 (?, ?)", ("interval", "5"))
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("check_interval", "300")) 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.commit()
conn.close() conn.close()
print(f"Datenbank '{DB_PATH}' wurde initialisiert inklusive Settings.") print(f"Datenbank '{DB_PATH}' wurde initialisiert inklusive Settings.")
+2
View File
@@ -462,6 +462,8 @@ button { font-family: inherit; }
.device-card:active { transform: translateY(0); } .device-card:active { transform: translateY(0); }
.device-card.is-disabled { cursor: default; opacity: 0.65; } .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-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 { .device-card::before {
content: ""; content: "";
+5 -1
View File
@@ -48,15 +48,19 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['grid']|safe }}</svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['grid']|safe }}</svg>
Dashboard Dashboard
</a> </a>
{% if current_user.can_view_devices %}
<a href="{{ url_for('devices') }}" class="nav-item {% if active_page == 'devices' %}active{% endif %}"> <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> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['cpu']|safe }}</svg>
Devices Devices
</a> </a>
{% if current_user.is_admin %} {% endif %}
{% if current_user.can_manage_switches %}
<a href="{{ url_for('switches') }}" class="nav-item {% if active_page == 'switches' %}active{% endif %}"> <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> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['share']|safe }}</svg>
Switches Switches
</a> </a>
{% endif %}
{% if current_user.is_admin %}
<a href="{{ url_for('users') }}" class="nav-item {% if active_page == 'users' %}active{% endif %}"> <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> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['users']|safe }}</svg>
Users Users
+46 -1
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "groups" %} {% set active_page = "groups" %}
{% block page_title %}Gruppen{% endblock %} {% block page_title %}Gruppen{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ groups|length }} Gruppen · Rechteverwaltung</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %}
{% block topbar_right %} {% block topbar_right %}
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal"> <button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
@@ -17,6 +17,41 @@
Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich. Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich.
</p> </p>
{# Virtuelle Admin-"Gruppe": rein informativ, kein Formular — Admin-Status
wird ausschließlich über den is_admin-Schalter auf der Users-Seite gesetzt. #}
<div class="card card-pad" style="margin-bottom:16px; opacity:0.85;">
<div class="section-head">
<div class="flex gap-2" style="align-items:center;">
<h3 style="font-size:16px;">Admin</h3>
<span class="pill admin">Systemrolle</span>
</div>
</div>
<p class="text-faint" style="font-size:12px; margin:-8px 0 14px;">
Admins dürfen immer alles — Rolle wird über <a href="{{ url_for('users') }}" style="color:var(--accent-strong); font-weight:600;">Users</a> vergeben, nicht hier.
</p>
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px;">
{% for cat_key, cat in permission_catalog.items() %}
<div>
<div class="permission-group-title">{{ cat['label'] }}</div>
<div class="check-list">
{% for key, label in cat['items'].items() %}
<label class="check-row" style="cursor:default; color:var(--text-faint);">
<input type="checkbox" checked disabled>
{{ label }}
</label>
{% endfor %}
</div>
</div>
{% endfor %}
<div>
<div class="permission-group-title">Mitglieder</div>
<p class="text-dim" style="font-size:13px;">
{% if admin_virtual_group.member_names %}{{ admin_virtual_group.member_names|join(', ') }}{% else %}Keine Admins vorhanden.{% endif %}
</p>
</div>
</div>
</div>
{% if groups %} {% if groups %}
<div style="display:flex; flex-direction:column; gap:16px;"> <div style="display:flex; flex-direction:column; gap:16px;">
{% for g in groups %} {% for g in groups %}
@@ -28,13 +63,21 @@
<div class="section-head"> <div class="section-head">
<div class="field" style="margin-bottom:0; max-width:280px; flex:1;"> <div class="field" style="margin-bottom:0; max-width:280px; flex:1;">
<label>Gruppenname</label> <label>Gruppenname</label>
<div class="flex gap-2" style="align-items:center;">
<input type="text" name="name" value="{{ g.name }}" required> <input type="text" name="name" value="{{ g.name }}" required>
{% if g.is_default %}<span class="pill user" style="white-space:nowrap;">Standard</span>{% endif %}
</div>
</div> </div>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<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> <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>
Speichern Speichern
</button> </button>
</div> </div>
{% if g.is_default %}
<p class="text-faint" style="font-size:11.5px; margin:-8px 0 8px;">
Standardgruppe — jeder neu angelegte Benutzer wird ihr automatisch zugeordnet. Kann nicht gelöscht werden.
</p>
{% endif %}
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px; margin-top:8px;"> <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px; margin-top:8px;">
{% for cat_key, cat in permission_catalog.items() %} {% for cat_key, cat in permission_catalog.items() %}
@@ -71,10 +114,12 @@
<span class="text-faint" style="font-size:12px;"> <span class="text-faint" style="font-size:12px;">
{% if g.member_names %}Mitglieder: {{ g.member_names|join(', ') }}{% else %}Keine Mitglieder{% endif %} {% if g.member_names %}Mitglieder: {{ g.member_names|join(', ') }}{% else %}Keine Mitglieder{% endif %}
</span> </span>
{% if not g.is_default %}
<form method="post" data-confirm="Gruppe „{{ g.name }}“ wirklich löschen? Mitglieder verlieren die zugehörigen Rechte."> <form method="post" data-confirm="Gruppe „{{ g.name }}“ wirklich löschen? Mitglieder verlieren die zugehörigen Rechte.">
<input type="hidden" name="delete_group" value="{{ g.id }}"> <input type="hidden" name="delete_group" value="{{ g.id }}">
<button type="submit" class="btn btn-sm btn-danger">Gruppe löschen</button> <button type="submit" class="btn btn-sm btn-danger">Gruppe löschen</button>
</form> </form>
{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
+26 -73
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "index" %} {% set active_page = "index" %}
{% block page_title %}Dashboard{% endblock %} {% block page_title %}Dashboard{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte im Bestand</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte{{ " im Bestand" if current_user.is_authenticated else "" }}</div>{% endblock %}
{% block topbar_right %} {% block topbar_right %}
<span class="timer-pill"><span class="dot"></span><span id="dashboard-timer">Nächste Prüfung in --s</span></span> <span class="timer-pill"><span class="dot"></span><span id="dashboard-timer">Nächste Prüfung in --s</span></span>
{% endblock %} {% endblock %}
@@ -17,62 +17,23 @@
<div class="stat-label">Offline</div> <div class="stat-label">Offline</div>
<div class="stat-value offline">{{ stats.offline }}</div> <div class="stat-value offline">{{ stats.offline }}</div>
</div> </div>
{% if current_user.is_authenticated %}
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Deaktiviert</div> <div class="stat-label">Deaktiviert</div>
<div class="stat-value disabled">{{ stats.disabled }}</div> <div class="stat-value disabled">{{ stats.disabled }}</div>
</div> </div>
{% endif %}
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Gesamt</div> <div class="stat-label">Gesamt</div>
<div class="stat-value">{{ stats.total }}</div> <div class="stat-value">{{ stats.total }}</div>
</div> </div>
</div> </div>
{% if not current_user.is_authenticated %}
{# ============================================================ #}
{# Öffentliche, schreibgeschützte Kurzübersicht (kein Login) #}
{# ============================================================ #}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr><th>Hostname</th><th>IP-Adresse</th><th>Status</th><th>Letzte Prüfung</th></tr>
</thead>
<tbody>
{% for d in devices %}
{% set st = status.get(d['mac'], 'unbekannt') %}
<tr>
<td class="cell-name">{{ d['name'] }}</td>
<td class="mono">{{ d['rpi_ip'] }}</td>
<td>
{% if d['is_active'] == 0 %}
<span class="pill disabled">Deaktiviert</span>
{% else %}
<span class="pill {{ st }}">{{ {'online':'Online','offline':'Offline','unbekannt':'Unbekannt'}[st] }}</span>
{% endif %}
</td>
<td class="text-dim">{{ last_checked.get(d['mac']) or '—' }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="4">Noch keine Geräte vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="text-faint mt-3" style="font-size:12px;">
Nur Lese-Ansicht. <a href="{{ url_for('login') }}" style="color:var(--accent-strong); font-weight:600;">Anmelden</a>,
um Geräte zu verwalten oder einen PoE-Neustart auszulösen.
</p>
{% else %}
{# ============================================================ #}
{# Vollständiges Dashboard für eingeloggte Benutzer #}
{# ============================================================ #}
{% if devices %} {% if devices %}
<div class="device-grid"> <div class="device-grid">
{% for d in devices %} {% for d in devices %}
{% set st = status.get(d['mac'], 'unbekannt') %} {% set st = status.get(d['mac'], 'unbekannt') %}
<div class="device-card {% if d['is_active'] == 0 %}is-disabled{% endif %}" <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="{{ 'offline' if d['is_active'] == 0 else st }}"
data-mac="{{ d['mac'] }}" data-mac="{{ d['mac'] }}"
data-name="{{ d['name'] }}" data-name="{{ d['name'] }}"
@@ -99,10 +60,23 @@
</div> </div>
{% else %} {% else %}
<div class="card card-pad" style="text-align:center; color:var(--text-faint);"> <div class="card card-pad" style="text-align:center; color:var(--text-faint);">
{% if current_user.is_authenticated %}
Noch keine Geräte angelegt. Füge welche unter <a href="{{ url_for('devices') }}" style="color:var(--accent-strong); font-weight:600;">Devices</a> hinzu. Noch keine Geräte angelegt. Füge welche unter <a href="{{ url_for('devices') }}" style="color:var(--accent-strong); font-weight:600;">Devices</a> hinzu.
{% else %}
Aktuell keine Geräte verfügbar.
{% endif %}
</div> </div>
{% endif %} {% endif %}
{% if not current_user.is_authenticated %}
<p class="text-faint mt-3" style="font-size:12px;">
Nur Lese-Ansicht — deaktivierte Geräte werden nicht angezeigt.
<a href="{{ url_for('login') }}" style="color:var(--accent-strong); font-weight:600;">Anmelden</a>,
um alle Geräte zu verwalten oder einen PoE-Neustart auszulösen.
</p>
{% endif %}
{% if current_user.is_authenticated %}
<!-- Device detail modal --> <!-- Device detail modal -->
<div class="modal-overlay" id="deviceModal"> <div class="modal-overlay" id="deviceModal">
<div class="modal"> <div class="modal">
@@ -159,7 +133,12 @@ document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ interval | int }}; const intervalMinutes = {{ interval | int }};
const intervalMilliseconds = intervalMinutes * 60 * 1000; const intervalMilliseconds = intervalMinutes * 60 * 1000;
const isAuthenticated = {{ "true" if current_user.is_authenticated else "false" }}; 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() { function updateTimer() {
const now = Date.now(); const now = Date.now();
@@ -171,38 +150,15 @@ document.addEventListener("DOMContentLoaded", () => {
if (remainingSec <= 1) window.location.reload(); if (remainingSec <= 1) window.location.reload();
} }
if (!isAuthenticated) {
// Öffentliche Ansicht: einfacher Reload-Timer, kein Zugriff auf /get_log nötig.
setInterval(updateTimer, 1000); setInterval(updateTimer, 1000);
updateTimer();
if (!isAuthenticated) {
return; return;
} }
let selectedCard = null, selectedMac = null, selectedName = null; 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 => { document.querySelectorAll(".device-card").forEach(card => {
const mac = card.dataset.mac; const mac = card.dataset.mac;
const restartKey = "restart_" + mac; const restartKey = "restart_" + mac;
@@ -263,9 +219,6 @@ document.addEventListener("DOMContentLoaded", () => {
.catch(err => { button.disabled = false; showToast("Fehler beim Starten des Neustarts.", "danger"); }); .catch(err => { button.disabled = false; showToast("Fehler beim Starten des Neustarts.", "danger"); });
}); });
} }
setInterval(updateTimer, 1000);
fetchLastLog();
}); });
</script> </script>
{% endblock %} {% endblock %}