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:
@@ -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
|
||||||
|
|||||||
+134
-21
@@ -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,16 +1190,20 @@ 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")
|
||||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
target = conn.execute("SELECT name, is_default FROM groups WHERE id=?", (group_id,)).fetchone()
|
||||||
conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,))
|
if target and target["is_default"]:
|
||||||
conn.execute("DELETE FROM groups WHERE id=?", (group_id,))
|
flash(f"Die Standardgruppe '{target['name']}' kann nicht gelöscht werden.", "danger")
|
||||||
conn.commit()
|
else:
|
||||||
flash("Gruppe gelöscht.", "success")
|
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()
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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: "";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
<input type="text" name="name" value="{{ g.name }}" required>
|
<div class="flex gap-2" style="align-items:center;">
|
||||||
|
<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 %}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setInterval(updateTimer, 1000);
|
||||||
|
updateTimer();
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Öffentliche Ansicht: einfacher Reload-Timer, kein Zugriff auf /get_log nötig.
|
|
||||||
setInterval(updateTimer, 1000);
|
|
||||||
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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user