From c4f682b5de21ef5f932ba308493db06398b662f3 Mon Sep 17 00:00:00 2001 From: alientim Date: Mon, 10 Aug 2026 15:53:15 +0200 Subject: [PATCH] UI-Fixes (Button-Wrap, Suchfeld-Icon, Log-Farben, Nav-Persistenz), manueller Check-Trigger, echter Live-Refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - section-head: Button ("+ Neue Zugangsdaten" etc.) rutscht bei langer Beschreibung nicht mehr in die nächste Zeile — bleibt immer rechts oben, der Beschreibungstext wickelt stattdessen innerhalb seiner eigenen Spalte. - Suchfeld-Icon überlappte den Platzhaltertext: eine spätere, generische Input-Regel mit gleicher Spezifität hat die padding-left-Regel des Lupe-Icons überschrieben (reine Reihenfolge-im-Stylesheet-Sache) — Selektor spezifischer gemacht. - Live-Log: Neustart-bezogene Zeilen (manueller Neustart, automatischer PoE-Restart bei Ausfall) werden jetzt orange markiert statt farblos. - Aufgeklappte Nav-Gruppen bleiben über Seitenwechsel hinweg erhalten (localStorage), zusätzlich zur automatischen Aufklappung der Gruppe der aktuell aktiven Seite. - Datei-Auswahl (Import) im Dark Mode gestylt (Text + Button), inkl. color-scheme-bewusster Browser-Widgets. - Manueller "Jetzt prüfen"-Trigger (Icon neben dem globalen Timer, nur Admins): startet rpi-check.service neu -> frisches Logfile + sofortiger Durchlauf, Countdown synchronisiert sich auf den manuellen Zeitpunkt. - Dashboard und Live-Log aktualisieren sich jetzt per AJAX im Hintergrund (kein voller Seiten-Reload mehr): neue Route /dashboard/tiles liefert das Kachel-Fragment (_dashboard_tiles.html, von index.html eingebunden und vom Live-Update nachgeladen), get_log() liefert den aktuellen Dateinamen als Header mit, damit die Anzeige nach einem Service-Neustart aktuell bleibt. - Root-Cause für "Intervall bleibt immer bei 300s": in der WSL-Testumgebung lief poe_wrapper.py nur als manuell gestarteter Hintergrundprozess statt als echter rpi-check.service — "systemctl restart" lief dadurch ins Leere. Echten systemd-Service in WSL eingerichtet und Intervalländerung end-to-end verifiziert (Log-Zeilenabstand folgt jetzt dem neu gesetzten Intervall). Co-Authored-By: Claude Sonnet 5 --- README.md | 19 +- srv/poe_manager/app.py | 81 ++++-- srv/poe_manager/static/css/style.css | 64 ++++- srv/poe_manager/static/js/app.js | 80 +++++- .../templates/_dashboard_tiles.html | 96 +++++++ srv/poe_manager/templates/activity_log.html | 1 + srv/poe_manager/templates/base.html | 7 +- srv/poe_manager/templates/index.html | 267 +++++++----------- srv/poe_manager/templates/logs.html | 21 +- 9 files changed, 438 insertions(+), 198 deletions(-) create mode 100644 srv/poe_manager/templates/_dashboard_tiles.html diff --git a/README.md b/README.md index 214745d..68de29f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,11 @@ Die App ermöglicht: der Countdown „Nächste Prüfung“ wird aus dem tatsächlichen letzten Log-Eintrag berechnet, nicht bei jedem Seitenaufruf neu gestartet, und läuft **global in der Topbar auf jeder Seite** (nicht nur im Dashboard) -- Live-Log-Ansicht mit farblicher Hervorhebung (online/offline) +- Live-Log-Ansicht mit farblicher Hervorhebung (online/offline/Neustart); + Dashboard und Live-Log aktualisieren sich automatisch im Hintergrund + (per AJAX, ohne vollen Seiten-Reload). Ein „Jetzt prüfen"-Icon neben dem + Timer (nur Admins) startet manuell einen sofortigen Prüf-Durchlauf — + der Countdown setzt sich dabei auf den manuell ausgelösten Zeitpunkt zurück - **Einheitliches Seiten-Layout**: die Topbar zeigt rechts ausschließlich den „Nächste Prüfung“-Timer; Anlegen-Buttons und eine kurze Beschreibung sitzen stattdessen direkt über der jeweiligen Tabelle. Die Listen @@ -241,6 +245,19 @@ python3 -m venv venv # auch aus dem Windows-Browser erreichbar ``` +**Hinweis zu `rpi-check.service`:** Der „Speichern & Service neustarten“-Button +unter Systemeinstellungen sowie das „Jetzt prüfen“-Icon funktionieren nur, +wenn `rpi-check.service` tatsächlich als echter systemd-Service läuft (viele +aktuelle WSL2-Installationen unterstützen systemd, siehe `wsl.conf` +`[boot] systemd=true`). Für lokale Tests reicht es, `etc/systemd/system/ +rpi-check.service` sowie `usr/local/bin/custom/poe.sh` per Symlink an ihre +Produktivpfade (`/etc/systemd/system/`, `/usr/local/bin/custom/`) zu legen +und `/srv/poe_manager` auf den Checkout zu verlinken, dann `systemctl +daemon-reload && systemctl enable --now rpi-check.service`. Ohne systemd +(z.B. `systemctl` nicht vorhanden) scheitert der Neustart-Aufruf lautlos — +das Intervall in der DB wird trotzdem aktualisiert, greift dann aber erst +nach einem manuellen Neustart des Prozesses. + ## Lokal starten (Entwicklung) ```bash diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index 3d5ca48..2a4079d 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -740,16 +740,10 @@ def get_device_status(devices): # Dashboard # --------------------------------------------------------------------------- -@app.route("/") -def index(): - """ - Dashboard als Kachel-Ansicht, in drei Abschnitten: Offline, Online, - Deaktiviert (jeweils alphabetisch) — jeweils nur gerendert, wenn nicht - leer. Ohne Login: keine deaktivierten Geräte, nur Online/Offline/Gesamt- - Statistik, keine Interaktion. Eingeloggt: alle Geräte, volle - Statistik-Kacheln, Klick-Details, manueller PoE-Neustart bzw. bei - deaktivierten Geräten eine Aktivieren-Option. - """ +def _dashboard_context(): + """Gemeinsam von index() und dem AJAX-Partial /dashboard/tiles genutzt, + damit das Live-Update des Dashboards (ohne vollen Seiten-Reload) exakt + dieselben Daten liefert wie der initiale Seitenaufruf.""" conn = get_db_connection() all_devices = conn.execute( "SELECT mac, name, rpi_ip, switch_hostname, port, is_active FROM devices ORDER BY name ASC" @@ -781,8 +775,7 @@ def index(): last_run_epoch_ms = int(last_run_at.timestamp() * 1000) if last_run_at else None - return render_template( - "index.html", + return dict( offline_devices=offline_devices, online_devices=online_devices, disabled_devices=disabled_devices, device_count=len(visible_devices), status=status, last_seen=last_seen, @@ -791,6 +784,54 @@ def index(): ) +@app.route("/") +def index(): + """ + Dashboard als Kachel-Ansicht, in drei Abschnitten: Offline, Online, + Deaktiviert (jeweils alphabetisch) — jeweils nur gerendert, wenn nicht + leer. Ohne Login: keine deaktivierten Geräte, nur Online/Offline/Gesamt- + Statistik, keine Interaktion. Eingeloggt: alle Geräte, volle + Statistik-Kacheln, Klick-Details, manueller PoE-Neustart bzw. bei + deaktivierten Geräten eine Aktivieren-Option. + """ + return render_template("index.html", **_dashboard_context()) + + +@app.route("/dashboard/tiles") +def dashboard_tiles(): + """AJAX-Partial für das Live-Update der Dashboard-Kacheln (Status, + Statistik) ohne vollen Seiten-Reload — liefert dasselbe Fragment, das + index.html beim initialen Aufruf einbindet (siehe _dashboard_tiles.html).""" + return render_template("_dashboard_tiles.html", **_dashboard_context()) + + +def _restart_check_service(): + """Startet rpi-check.service (poe_wrapper.py -> poe.sh) neu, damit ein + geändertes Prüfintervall sofort greift (poe.sh liest SLEEP nur einmal + beim Start aus der DB) — läuft dabei automatisch auch ein sofortiger + Prüf-Durchlauf mit frischem Logfile an. Schlägt in Umgebungen ohne + systemd (z.B. Container) lautlos fehl, statt die Anfrage abzubrechen.""" + try: + subprocess.run(["systemctl", "restart", "rpi-check.service"], check=False) + except FileNotFoundError: + pass # z.B. lokale Entwicklungsumgebung ohne systemd + + +@app.route("/check/run-now", methods=["POST"]) +@login_required +def run_check_now(): + """Manueller 'Jetzt prüfen'-Trigger (Zahnrad-Icon neben dem Prüf-Timer): + startet denselben Service neu wie eine Intervalländerung — das erzeugt + sofort ein frisches Logfile mit einem neuen Durchlauf, der Countdown + 'Nächste Prüfung' setzt sich dadurch automatisch auf den manuell + ausgelösten Zeitpunkt zurück.""" + if not current_user.is_admin: + return {"success": False, "msg": "Keine Berechtigung."}, 403 + _restart_check_service() + log_action("check.run_now", "Prüfintervall") + return {"success": True, "msg": "Prüfung gestartet."} + + # --------------------------------------------------------------------------- # Settings # --------------------------------------------------------------------------- @@ -809,11 +850,7 @@ def settings(): set_setting("interval", new_interval) set_setting("check_interval", new_interval * 60) log_action("settings.update", "Prüfintervall", f"{new_interval} Minuten") - - try: - subprocess.run(["systemctl", "restart", "rpi-check.service"], check=False) - except FileNotFoundError: - pass # z.B. lokale Entwicklungsumgebung ohne systemd + _restart_check_service() flash(f"Intervall auf {new_interval} Minuten gesetzt und Service neu gestartet!", "success") return redirect(url_for("settings")) @@ -1594,9 +1631,15 @@ def get_log(): return "Keine Logfiles gefunden." try: with open(latest_log, "r") as f: - return f.read() + content = f.read() except Exception as e: - return f"Fehler beim Lesen des Logs: {e}" + content = f"Fehler beim Lesen des Logs: {e}" + # Dateiname als Header mitschicken, damit die Anzeige auch nach einem + # Service-Neustart (neues Logfile, z.B. durch "Jetzt prüfen") ohne + # Seiten-Reload aktuell bleibt. + response = app.response_class(content, mimetype="text/plain") + response.headers["X-Log-Name"] = os.path.basename(latest_log) + return response @app.route("/logs") diff --git a/srv/poe_manager/static/css/style.css b/srv/poe_manager/static/css/style.css index 64523d9..13c861d 100644 --- a/srv/poe_manager/static/css/style.css +++ b/srv/poe_manager/static/css/style.css @@ -429,12 +429,16 @@ button { font-family: inherit; } .section-head { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; - gap: 12px; + gap: 16px; margin-bottom: 18px; - flex-wrap: wrap; } +/* Beschreibungstext darf mehrzeilig umbrechen, ohne dass der Button + (z.B. "+ Neue Zugangsdaten") mit in die nächste Zeile rutscht — der + bleibt immer rechts oben in derselben Zeile. */ +.section-head > div:first-child { flex: 1 1 auto; min-width: 0; } +.section-head > .btn { flex-shrink: 0; } .section-head h2 { font-size: 20px; } .section-head .hint { color: var(--text-faint); font-size: 12.5px; margin-top: 3px; } @@ -529,6 +533,22 @@ button { font-family: inherit; } 100% { box-shadow: 0 0 0 0 rgba(47,208,122,0); } } +.timer-pill-refresh { + display: inline-flex; align-items: center; justify-content: center; + width: 18px; height: 18px; + margin-left: 2px; + padding: 0; + border: none; + background: none; + color: var(--text-faint); + cursor: pointer; + border-radius: 50%; +} +.timer-pill-refresh svg { width: 12px; height: 12px; } +.timer-pill-refresh:hover { color: var(--accent-strong); background: var(--accent-dim); } +.timer-pill-refresh.spinning svg { animation: spin 0.8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + /* ========================================================================== Device grid (dashboard) ========================================================================== */ @@ -663,7 +683,7 @@ button { font-family: inherit; } color: var(--text-faint); pointer-events: none; } -.search-input input { padding-left: 34px; } +.search-input input[type="text"] { padding-left: 34px; } table.data-table { width: 100%; @@ -753,6 +773,40 @@ input[type="text"], input[type="password"], input[type="number"], input[type="em transition: border-color 0.15s ease, box-shadow 0.15s ease; } input::placeholder { color: var(--text-faint); } + +/* Datei-Auswahl (z.B. Import/Export) — der native Button/"keine Datei + ausgewählt"-Text folgt sonst nicht dem Farbschema und ist im Dark Mode + kaum lesbar (dunkler Text auf dunklem Grund). */ +input[type="file"] { + width: 100%; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 9px; + font-size: 13px; + padding: 6px 8px; +} +input[type="file"]::file-selector-button, +input[type="file"]::-webkit-file-upload-button { + -webkit-appearance: none; + appearance: none; + background: var(--bg-card-hover); + color: var(--text); + border: 1px solid var(--border); + border-radius: 7px; + padding: 7px 12px; + margin-right: 10px; + font-size: 12.5px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} +input[type="file"]::file-selector-button:hover, +input[type="file"]::-webkit-file-upload-button:hover { + background: var(--bg-card); + border-color: var(--text-faint); +} input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); @@ -974,7 +1028,7 @@ select { .log-line.online { color: #4ade80; } .log-line.offline { color: #f87171; } .log-line.sep { color: #3a4050; } -.log-line.manual { color: #ffb454; } +.log-line.restart { color: #ffb454; } /* ========================================================================== Permission checklists (Gruppen) diff --git a/srv/poe_manager/static/js/app.js b/srv/poe_manager/static/js/app.js index 6ba24a3..6b3abf2 100644 --- a/srv/poe_manager/static/js/app.js +++ b/srv/poe_manager/static/js/app.js @@ -206,11 +206,39 @@ } catch (e) { /* noop */ } } - /* Nav-Gruppen mit Untermenü (z.B. "Logs") auf-/zuklappen */ + /* Nav-Gruppen mit Untermenü (z.B. "Logs") auf-/zuklappen — der manuell + aufgeklappte Zustand bleibt über Seitenwechsel hinweg erhalten + (localStorage), zusätzlich zur automatischen Aufklappung der Gruppe + der aktuell aktiven Seite (server-seitig via "expanded"-Klasse). */ + const NAV_EXPANDED_KEY = "poe-nav-expanded"; + + function getExpandedNavGroups() { + try { + return new Set(JSON.parse(localStorage.getItem(NAV_EXPANDED_KEY) || "[]")); + } catch (e) { + return new Set(); + } + } + + function saveExpandedNavGroups(set) { + localStorage.setItem(NAV_EXPANDED_KEY, JSON.stringify(Array.from(set))); + } + function initNavGroups() { + const expanded = getExpandedNavGroups(); + document.querySelectorAll("[data-nav-group]").forEach((group) => { + const key = group.dataset.navGroupKey; + if (key && expanded.has(key)) group.classList.add("expanded"); + }); document.querySelectorAll("[data-nav-group-toggle]").forEach((btn) => { btn.addEventListener("click", () => { - btn.closest("[data-nav-group]").classList.toggle("expanded"); + const group = btn.closest("[data-nav-group]"); + const isExpanded = group.classList.toggle("expanded"); + const key = group.dataset.navGroupKey; + if (!key) return; + const set = getExpandedNavGroups(); + if (isExpanded) set.add(key); else set.delete(key); + saveExpandedNavGroups(set); }); }); } @@ -221,11 +249,16 @@ const pill = document.getElementById("global-timer-pill"); const timerEl = document.getElementById("global-timer"); if (!pill || !timerEl) return; - const lastRunMs = parseInt(pill.dataset.lastRunMs, 10); - const intervalMs = parseInt(pill.dataset.intervalMs, 10); - if (!intervalMs) { timerEl.textContent = "--"; return; } + // lastRunMs/intervalMs werden bei JEDEM Tick frisch aus dem DOM gelesen + // (nicht einmalig gecacht) — so kann z.B. das Dashboard nach einem + // Live-Update (siehe dashboard-tiles.js) oder ein manueller "Jetzt + // prüfen"-Klick das data-last-run-ms-Attribut aktualisieren und der + // global laufende Countdown übernimmt das automatisch. function update() { + const lastRunMs = parseInt(pill.dataset.lastRunMs, 10); + const intervalMs = parseInt(pill.dataset.intervalMs, 10); + if (!intervalMs) { timerEl.textContent = "--"; return; } let remainingMs = intervalMs; if (!isNaN(lastRunMs) && lastRunMs > 0) { const elapsed = Date.now() - lastRunMs; @@ -237,6 +270,42 @@ setInterval(update, 1000); } + /* "Jetzt prüfen"-Button neben dem globalen Timer (nur Admins): startet + rpi-check.service neu (frisches Logfile + sofortiger Durchlauf) und + meldet das per Custom Event, damit Dashboard/Live-Log sich selbst + aktualisieren können, ohne dass app.js die Seiteninhalte kennen muss. */ + function initRunCheckNow() { + const btn = document.getElementById("run-check-now"); + if (!btn) return; + btn.addEventListener("click", () => { + btn.disabled = true; + btn.classList.add("spinning"); + fetch("/check/run-now", { method: "POST" }) + .then((r) => r.json()) + .then((data) => { + if (!data.success) { + window.showToast(data.msg || "Fehler beim Starten der Prüfung.", "danger"); + btn.disabled = false; + btn.classList.remove("spinning"); + return; + } + window.showToast(data.msg || "Prüfung gestartet.", "success"); + // Der Service braucht einen Moment, um neu zu starten und den + // ersten Durchlauf ins frische Logfile zu schreiben. + setTimeout(() => { + document.dispatchEvent(new CustomEvent("poe:check-triggered")); + btn.disabled = false; + btn.classList.remove("spinning"); + }, 1500); + }) + .catch(() => { + window.showToast("Fehler beim Starten der Prüfung.", "danger"); + btn.disabled = false; + btn.classList.remove("spinning"); + }); + }); + } + /* ---------------- Sortierbare Tabellen ---------------- */ /* * Klick auf sortiert die Tabelle. Zwei Modi: @@ -300,6 +369,7 @@ initFlashedMessages(); initNavGroups(); initCheckTimer(); + initRunCheckNow(); initSortableTables(); document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); }); diff --git a/srv/poe_manager/templates/_dashboard_tiles.html b/srv/poe_manager/templates/_dashboard_tiles.html new file mode 100644 index 0000000..c04c0d1 --- /dev/null +++ b/srv/poe_manager/templates/_dashboard_tiles.html @@ -0,0 +1,96 @@ + +
+ +
+
+
Online
+
{{ stats.online }}
+
+
+
Offline
+
{{ stats.offline }}
+
+ {% if current_user.is_authenticated %} +
+
Deaktiviert
+
{{ stats.disabled }}
+
+ {% endif %} +
+
Gesamt
+
{{ stats.total }}
+
+
+ +{% macro device_tile(d) %} +{% set st = status.get(d['mac'], 'unbekannt') %} +
+
+
+ +
+ {% if d['is_active'] == 0 %} + Deaktiviert + {% else %} + {{ {'online':'Online','offline':'Offline','unbekannt':'Unbekannt'}[st] }} + {% endif %} +
+
{{ d['name'] }}
+
{{ d['rpi_ip'] }}
+
+{% endmacro %} + +{% if device_count %} + + {% if offline_devices %} +
+
Offline {{ offline_devices|length }}
+
+ {% for d in offline_devices %}{{ device_tile(d) }}{% endfor %} +
+
+ {% endif %} + + {% if online_devices %} +
+
Online {{ online_devices|length }}
+
+ {% for d in online_devices %}{{ device_tile(d) }}{% endfor %} +
+
+ {% endif %} + + {% if disabled_devices %} +
+
Deaktiviert {{ disabled_devices|length }}
+
+ {% for d in disabled_devices %}{{ device_tile(d) }}{% endfor %} +
+
+ {% endif %} + + + +{% 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 %} + +
diff --git a/srv/poe_manager/templates/activity_log.html b/srv/poe_manager/templates/activity_log.html index 404ea49..225a7b7 100644 --- a/srv/poe_manager/templates/activity_log.html +++ b/srv/poe_manager/templates/activity_log.html @@ -28,6 +28,7 @@ "group.assign_admins": "Admin-Zuweisung geändert", "profile.update": "Profil aktualisiert", "profile.password": "Passwort geändert", + "check.run_now": "Prüfung manuell gestartet", } %} {% set action_icons = { "delete": '', diff --git a/srv/poe_manager/templates/base.html b/srv/poe_manager/templates/base.html index b58da40..4c3f0fa 100644 --- a/srv/poe_manager/templates/base.html +++ b/srv/poe_manager/templates/base.html @@ -42,7 +42,7 @@ {% for item in nav_items_ordered %} {% if item.children %} {% set child_active = item.children|selectattr('endpoint', 'equalto', request.endpoint)|list %} -