UI-Fixes (Button-Wrap, Suchfeld-Icon, Log-Farben, Nav-Persistenz), manueller Check-Trigger, echter Live-Refresh
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,11 @@ Die App ermöglicht:
|
|||||||
der Countdown „Nächste Prüfung“ wird aus dem tatsächlichen letzten
|
der Countdown „Nächste Prüfung“ wird aus dem tatsächlichen letzten
|
||||||
Log-Eintrag berechnet, nicht bei jedem Seitenaufruf neu gestartet, und
|
Log-Eintrag berechnet, nicht bei jedem Seitenaufruf neu gestartet, und
|
||||||
läuft **global in der Topbar auf jeder Seite** (nicht nur im Dashboard)
|
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
|
- **Einheitliches Seiten-Layout**: die Topbar zeigt rechts ausschließlich
|
||||||
den „Nächste Prüfung“-Timer; Anlegen-Buttons und eine kurze Beschreibung
|
den „Nächste Prüfung“-Timer; Anlegen-Buttons und eine kurze Beschreibung
|
||||||
sitzen stattdessen direkt über der jeweiligen Tabelle. Die Listen
|
sitzen stattdessen direkt über der jeweiligen Tabelle. Die Listen
|
||||||
@@ -241,6 +245,19 @@ python3 -m venv venv
|
|||||||
# auch aus dem Windows-Browser erreichbar
|
# 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)
|
## Lokal starten (Entwicklung)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+62
-19
@@ -740,16 +740,10 @@ def get_device_status(devices):
|
|||||||
# Dashboard
|
# Dashboard
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@app.route("/")
|
def _dashboard_context():
|
||||||
def index():
|
"""Gemeinsam von index() und dem AJAX-Partial /dashboard/tiles genutzt,
|
||||||
"""
|
damit das Live-Update des Dashboards (ohne vollen Seiten-Reload) exakt
|
||||||
Dashboard als Kachel-Ansicht, in drei Abschnitten: Offline, Online,
|
dieselben Daten liefert wie der initiale Seitenaufruf."""
|
||||||
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.
|
|
||||||
"""
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
all_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"
|
||||||
@@ -781,8 +775,7 @@ def index():
|
|||||||
|
|
||||||
last_run_epoch_ms = int(last_run_at.timestamp() * 1000) if last_run_at else None
|
last_run_epoch_ms = int(last_run_at.timestamp() * 1000) if last_run_at else None
|
||||||
|
|
||||||
return render_template(
|
return dict(
|
||||||
"index.html",
|
|
||||||
offline_devices=offline_devices, online_devices=online_devices, disabled_devices=disabled_devices,
|
offline_devices=offline_devices, online_devices=online_devices, disabled_devices=disabled_devices,
|
||||||
device_count=len(visible_devices),
|
device_count=len(visible_devices),
|
||||||
status=status, last_seen=last_seen,
|
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
|
# Settings
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -809,11 +850,7 @@ def settings():
|
|||||||
set_setting("interval", new_interval)
|
set_setting("interval", new_interval)
|
||||||
set_setting("check_interval", new_interval * 60)
|
set_setting("check_interval", new_interval * 60)
|
||||||
log_action("settings.update", "Prüfintervall", f"{new_interval} Minuten")
|
log_action("settings.update", "Prüfintervall", f"{new_interval} Minuten")
|
||||||
|
_restart_check_service()
|
||||||
try:
|
|
||||||
subprocess.run(["systemctl", "restart", "rpi-check.service"], check=False)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass # z.B. lokale Entwicklungsumgebung ohne systemd
|
|
||||||
|
|
||||||
flash(f"Intervall auf {new_interval} Minuten gesetzt und Service neu gestartet!", "success")
|
flash(f"Intervall auf {new_interval} Minuten gesetzt und Service neu gestartet!", "success")
|
||||||
return redirect(url_for("settings"))
|
return redirect(url_for("settings"))
|
||||||
@@ -1594,9 +1631,15 @@ def get_log():
|
|||||||
return "Keine Logfiles gefunden."
|
return "Keine Logfiles gefunden."
|
||||||
try:
|
try:
|
||||||
with open(latest_log, "r") as f:
|
with open(latest_log, "r") as f:
|
||||||
return f.read()
|
content = f.read()
|
||||||
except Exception as e:
|
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")
|
@app.route("/logs")
|
||||||
|
|||||||
@@ -429,12 +429,16 @@ button { font-family: inherit; }
|
|||||||
|
|
||||||
.section-head {
|
.section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
margin-bottom: 18px;
|
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 h2 { font-size: 20px; }
|
||||||
.section-head .hint { color: var(--text-faint); font-size: 12.5px; margin-top: 3px; }
|
.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); }
|
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)
|
Device grid (dashboard)
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
@@ -663,7 +683,7 @@ button { font-family: inherit; }
|
|||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.search-input input { padding-left: 34px; }
|
.search-input input[type="text"] { padding-left: 34px; }
|
||||||
|
|
||||||
table.data-table {
|
table.data-table {
|
||||||
width: 100%;
|
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;
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
input::placeholder { color: var(--text-faint); }
|
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 {
|
input:focus, select:focus, textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
@@ -974,7 +1028,7 @@ select {
|
|||||||
.log-line.online { color: #4ade80; }
|
.log-line.online { color: #4ade80; }
|
||||||
.log-line.offline { color: #f87171; }
|
.log-line.offline { color: #f87171; }
|
||||||
.log-line.sep { color: #3a4050; }
|
.log-line.sep { color: #3a4050; }
|
||||||
.log-line.manual { color: #ffb454; }
|
.log-line.restart { color: #ffb454; }
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
Permission checklists (Gruppen)
|
Permission checklists (Gruppen)
|
||||||
|
|||||||
@@ -206,11 +206,39 @@
|
|||||||
} catch (e) { /* noop */ }
|
} 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() {
|
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) => {
|
document.querySelectorAll("[data-nav-group-toggle]").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
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 pill = document.getElementById("global-timer-pill");
|
||||||
const timerEl = document.getElementById("global-timer");
|
const timerEl = document.getElementById("global-timer");
|
||||||
if (!pill || !timerEl) return;
|
if (!pill || !timerEl) 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 lastRunMs = parseInt(pill.dataset.lastRunMs, 10);
|
||||||
const intervalMs = parseInt(pill.dataset.intervalMs, 10);
|
const intervalMs = parseInt(pill.dataset.intervalMs, 10);
|
||||||
if (!intervalMs) { timerEl.textContent = "--"; return; }
|
if (!intervalMs) { timerEl.textContent = "--"; return; }
|
||||||
|
|
||||||
function update() {
|
|
||||||
let remainingMs = intervalMs;
|
let remainingMs = intervalMs;
|
||||||
if (!isNaN(lastRunMs) && lastRunMs > 0) {
|
if (!isNaN(lastRunMs) && lastRunMs > 0) {
|
||||||
const elapsed = Date.now() - lastRunMs;
|
const elapsed = Date.now() - lastRunMs;
|
||||||
@@ -237,6 +270,42 @@
|
|||||||
setInterval(update, 1000);
|
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 ---------------- */
|
/* ---------------- Sortierbare Tabellen ---------------- */
|
||||||
/*
|
/*
|
||||||
* Klick auf <th data-sort-key="..."> sortiert die Tabelle. Zwei Modi:
|
* Klick auf <th data-sort-key="..."> sortiert die Tabelle. Zwei Modi:
|
||||||
@@ -300,6 +369,7 @@
|
|||||||
initFlashedMessages();
|
initFlashedMessages();
|
||||||
initNavGroups();
|
initNavGroups();
|
||||||
initCheckTimer();
|
initCheckTimer();
|
||||||
|
initRunCheckNow();
|
||||||
initSortableTables();
|
initSortableTables();
|
||||||
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<!-- Live-Update-fähiges Fragment: wird von index.html initial eingebunden UND
|
||||||
|
unverändert von /dashboard/tiles (AJAX) nachgeladen, damit das Dashboard
|
||||||
|
ohne vollen Seiten-Reload aktuell bleibt (siehe dashboard-tiles.js im
|
||||||
|
scripts-Block von index.html). -->
|
||||||
|
<div id="dashboard-tiles" data-last-run-ms="{{ last_run_epoch_ms or '' }}">
|
||||||
|
|
||||||
|
<div class="stat-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Online</div>
|
||||||
|
<div class="stat-value online">{{ stats.online }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Offline</div>
|
||||||
|
<div class="stat-value offline">{{ stats.offline }}</div>
|
||||||
|
</div>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Deaktiviert</div>
|
||||||
|
<div class="stat-value disabled">{{ stats.disabled }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Gesamt</div>
|
||||||
|
<div class="stat-value">{{ stats.total }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% macro device_tile(d) %}
|
||||||
|
{% set st = status.get(d['mac'], 'unbekannt') %}
|
||||||
|
<div class="device-card {% if d['is_active'] == 0 %}is-disabled{% endif %} {% if not current_user.is_authenticated %}is-readonly{% endif %}"
|
||||||
|
data-status="{{ 'disabled' if d['is_active'] == 0 else st }}"
|
||||||
|
data-mac="{{ d['mac'] }}"
|
||||||
|
data-name="{{ d['name'] }}"
|
||||||
|
data-ip="{{ d['rpi_ip'] }}"
|
||||||
|
data-switch="{{ d['switch_hostname'] or '-' }}"
|
||||||
|
data-port="{{ d['port'] or '-' }}"
|
||||||
|
data-active="{{ d['is_active'] }}"
|
||||||
|
data-checked="{{ last_checked.get(d['mac']) or '-' }}"
|
||||||
|
{% if last_seen.get(d['mac']) %}title="{{ last_seen[d['mac']] }}"{% endif %}>
|
||||||
|
<div class="dc-top">
|
||||||
|
<div class="dc-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2.5"/><path d="M8 2v3M16 2v3M8 19v3M16 19v3M2 8h3M2 16h3M19 8h3M19 16h3"/></svg>
|
||||||
|
</div>
|
||||||
|
{% 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 %}
|
||||||
|
</div>
|
||||||
|
<div class="dc-name">{{ d['name'] }}</div>
|
||||||
|
<div class="dc-ip">{{ d['rpi_ip'] }}</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% if device_count %}
|
||||||
|
|
||||||
|
{% if offline_devices %}
|
||||||
|
<div class="dash-section">
|
||||||
|
<div class="dash-section-title">Offline <span class="pill offline">{{ offline_devices|length }}</span></div>
|
||||||
|
<div class="device-grid">
|
||||||
|
{% for d in offline_devices %}{{ device_tile(d) }}{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if online_devices %}
|
||||||
|
<div class="dash-section">
|
||||||
|
<div class="dash-section-title">Online <span class="pill online">{{ online_devices|length }}</span></div>
|
||||||
|
<div class="device-grid">
|
||||||
|
{% for d in online_devices %}{{ device_tile(d) }}{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if disabled_devices %}
|
||||||
|
<div class="dash-section">
|
||||||
|
<div class="dash-section-title">Deaktiviert <span class="pill disabled">{{ disabled_devices|length }}</span></div>
|
||||||
|
<div class="device-grid">
|
||||||
|
{% for d in disabled_devices %}{{ device_tile(d) }}{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p id="noSearchResults" class="text-faint hidden" style="text-align:center; padding:30px 0;">Kein Gerät gefunden.</p>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<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.
|
||||||
|
{% else %}
|
||||||
|
Aktuell keine Geräte verfügbar.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"group.assign_admins": "Admin-Zuweisung geändert",
|
"group.assign_admins": "Admin-Zuweisung geändert",
|
||||||
"profile.update": "Profil aktualisiert",
|
"profile.update": "Profil aktualisiert",
|
||||||
"profile.password": "Passwort geändert",
|
"profile.password": "Passwort geändert",
|
||||||
|
"check.run_now": "Prüfung manuell gestartet",
|
||||||
} %}
|
} %}
|
||||||
{% set action_icons = {
|
{% set action_icons = {
|
||||||
"delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
|
"delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
{% for item in nav_items_ordered %}
|
{% for item in nav_items_ordered %}
|
||||||
{% if item.children %}
|
{% if item.children %}
|
||||||
{% set child_active = item.children|selectattr('endpoint', 'equalto', request.endpoint)|list %}
|
{% set child_active = item.children|selectattr('endpoint', 'equalto', request.endpoint)|list %}
|
||||||
<div class="nav-group {% if child_active %}expanded active-group{% endif %}" data-nav-group>
|
<div class="nav-group {% if child_active %}expanded active-group{% endif %}" data-nav-group data-nav-group-key="{{ item.key }}">
|
||||||
<button type="button" class="nav-group-toggle" data-nav-group-toggle>
|
<button type="button" class="nav-group-toggle" data-nav-group-toggle>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[item.icon]|safe }}</svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[item.icon]|safe }}</svg>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
@@ -116,6 +116,11 @@
|
|||||||
data-last-run-ms="{{ global_last_run_epoch_ms or '' }}"
|
data-last-run-ms="{{ global_last_run_epoch_ms or '' }}"
|
||||||
data-interval-ms="{{ (global_check_interval * 60000) if global_check_interval else '' }}">
|
data-interval-ms="{{ (global_check_interval * 60000) if global_check_interval else '' }}">
|
||||||
<span class="dot"></span>Nächste Prüfung in <span id="global-timer">--</span>s
|
<span class="dot"></span>Nächste Prüfung in <span id="global-timer">--</span>s
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<button type="button" class="timer-pill-refresh" id="run-check-now" title="Jetzt prüfen">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{% if not current_user.is_authenticated %}
|
{% if not current_user.is_authenticated %}
|
||||||
<a href="{{ url_for('login') }}" class="btn btn-primary">
|
<a href="{{ url_for('login') }}" class="btn btn-primary">
|
||||||
|
|||||||
@@ -10,94 +10,7 @@
|
|||||||
<input type="text" id="tileSearch" placeholder="Gerät suchen…">
|
<input type="text" id="tileSearch" placeholder="Gerät suchen…">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-row">
|
{% include "_dashboard_tiles.html" %}
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">Online</div>
|
|
||||||
<div class="stat-value online">{{ stats.online }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">Offline</div>
|
|
||||||
<div class="stat-value offline">{{ stats.offline }}</div>
|
|
||||||
</div>
|
|
||||||
{% if current_user.is_authenticated %}
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">Deaktiviert</div>
|
|
||||||
<div class="stat-value disabled">{{ stats.disabled }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">Gesamt</div>
|
|
||||||
<div class="stat-value">{{ stats.total }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% macro device_tile(d) %}
|
|
||||||
{% set st = status.get(d['mac'], 'unbekannt') %}
|
|
||||||
<div class="device-card {% if d['is_active'] == 0 %}is-disabled{% endif %} {% if not current_user.is_authenticated %}is-readonly{% endif %}"
|
|
||||||
data-status="{{ 'disabled' if d['is_active'] == 0 else st }}"
|
|
||||||
data-mac="{{ d['mac'] }}"
|
|
||||||
data-name="{{ d['name'] }}"
|
|
||||||
data-ip="{{ d['rpi_ip'] }}"
|
|
||||||
data-switch="{{ d['switch_hostname'] or '-' }}"
|
|
||||||
data-port="{{ d['port'] or '-' }}"
|
|
||||||
data-active="{{ d['is_active'] }}"
|
|
||||||
data-checked="{{ last_checked.get(d['mac']) or '-' }}"
|
|
||||||
{% if last_seen.get(d['mac']) %}title="{{ last_seen[d['mac']] }}"{% endif %}>
|
|
||||||
<div class="dc-top">
|
|
||||||
<div class="dc-icon">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2.5"/><path d="M8 2v3M16 2v3M8 19v3M16 19v3M2 8h3M2 16h3M19 8h3M19 16h3"/></svg>
|
|
||||||
</div>
|
|
||||||
{% 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 %}
|
|
||||||
</div>
|
|
||||||
<div class="dc-name">{{ d['name'] }}</div>
|
|
||||||
<div class="dc-ip">{{ d['rpi_ip'] }}</div>
|
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
{% if device_count %}
|
|
||||||
|
|
||||||
{% if offline_devices %}
|
|
||||||
<div class="dash-section">
|
|
||||||
<div class="dash-section-title">Offline <span class="pill offline">{{ offline_devices|length }}</span></div>
|
|
||||||
<div class="device-grid">
|
|
||||||
{% for d in offline_devices %}{{ device_tile(d) }}{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if online_devices %}
|
|
||||||
<div class="dash-section">
|
|
||||||
<div class="dash-section-title">Online <span class="pill online">{{ online_devices|length }}</span></div>
|
|
||||||
<div class="device-grid">
|
|
||||||
{% for d in online_devices %}{{ device_tile(d) }}{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if disabled_devices %}
|
|
||||||
<div class="dash-section">
|
|
||||||
<div class="dash-section-title">Deaktiviert <span class="pill disabled">{{ disabled_devices|length }}</span></div>
|
|
||||||
<div class="device-grid">
|
|
||||||
{% for d in disabled_devices %}{{ device_tile(d) }}{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<p id="noSearchResults" class="text-faint hidden" style="text-align:center; padding:30px 0;">Kein Gerät gefunden.</p>
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<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.
|
|
||||||
{% else %}
|
|
||||||
Aktuell keine Geräte verfügbar.
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if not current_user.is_authenticated %}
|
{% if not current_user.is_authenticated %}
|
||||||
<p class="text-faint mt-3" style="font-size:12px;">
|
<p class="text-faint mt-3" style="font-size:12px;">
|
||||||
@@ -167,34 +80,17 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const intervalMinutes = {{ global_check_interval | int }};
|
|
||||||
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" }};
|
||||||
// Vom Server ermittelter Zeitpunkt des letzten echten Prüf-Durchlaufs
|
let selectedCard = null, selectedMac = null, selectedName = null;
|
||||||
// (aus dem Logfile) — damit startet der Countdown nicht bei jedem
|
const restartButton = document.getElementById("restartButton");
|
||||||
// Seitenaufruf wieder von vorn, sondern zeigt die tatsächlich
|
const activateButton = document.getElementById("activateButton");
|
||||||
// verbleibende Zeit bis zur nächsten Prüfung durch poe.sh. Der
|
const searchInput = document.getElementById("tileSearch");
|
||||||
// sichtbare Countdown selbst läuft global in der Topbar (app.js); hier
|
|
||||||
// wird nur überwacht, wann ein neuer Durchlauf fertig ist, um die
|
|
||||||
// Dashboard-Kacheln dann automatisch neu zu laden.
|
|
||||||
const lastRunAt = {{ global_last_run_epoch_ms | tojson }};
|
|
||||||
let lastUpdateTime = lastRunAt || Date.now();
|
|
||||||
|
|
||||||
function checkForReload() {
|
|
||||||
const now = Date.now();
|
|
||||||
const elapsed = now - lastUpdateTime;
|
|
||||||
const remainingMs = intervalMilliseconds - (elapsed % intervalMilliseconds);
|
|
||||||
if (Math.ceil(remainingMs / 1000) <= 1) window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intervalMilliseconds) setInterval(checkForReload, 1000);
|
|
||||||
|
|
||||||
// Suchfilter über alle Abschnitte hinweg; Abschnitt wird komplett
|
// Suchfilter über alle Abschnitte hinweg; Abschnitt wird komplett
|
||||||
// ausgeblendet, wenn keine seiner Kacheln mehr passt.
|
// ausgeblendet, wenn keine seiner Kacheln mehr passt. Wird nach jedem
|
||||||
const searchInput = document.getElementById("tileSearch");
|
// Live-Update erneut angewendet, damit ein aktiver Filter erhalten bleibt.
|
||||||
if (searchInput) {
|
function applySearchFilter(query) {
|
||||||
searchInput.addEventListener("input", () => {
|
const q = (query || "").trim().toLowerCase();
|
||||||
const q = searchInput.value.trim().toLowerCase();
|
|
||||||
let anyVisible = false;
|
let anyVisible = false;
|
||||||
document.querySelectorAll(".dash-section").forEach(section => {
|
document.querySelectorAll(".dash-section").forEach(section => {
|
||||||
let sectionHasMatch = false;
|
let sectionHasMatch = false;
|
||||||
@@ -208,17 +104,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
const noResults = document.getElementById("noSearchResults");
|
const noResults = document.getElementById("noSearchResults");
|
||||||
if (noResults) noResults.classList.toggle("hidden", anyVisible || q === "");
|
if (noResults) noResults.classList.toggle("hidden", anyVisible || q === "");
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
if (searchInput) searchInput.addEventListener("input", () => applySearchFilter(searchInput.value));
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
// Klick-Verhalten der Kacheln (Detail-Modal, Neustart/Aktivieren) — wird
|
||||||
return;
|
// nach jedem Live-Update erneut auf die frisch eingefügten Kacheln
|
||||||
}
|
// angewendet, da deren alte DOM-Knoten beim Austausch verworfen werden.
|
||||||
|
function bindDeviceCards() {
|
||||||
let selectedCard = null, selectedMac = null, selectedName = null;
|
if (!isAuthenticated) return;
|
||||||
const restartButton = document.getElementById("restartButton");
|
|
||||||
const activateButton = document.getElementById("activateButton");
|
|
||||||
|
|
||||||
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;
|
||||||
@@ -248,6 +141,60 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
PoeUI.openModal("deviceModal");
|
PoeUI.openModal("deviceModal");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
bindDeviceCards();
|
||||||
|
|
||||||
|
// Live-Update der Dashboard-Kacheln ohne vollen Seiten-Reload: tauscht
|
||||||
|
// nur den #dashboard-tiles-Container gegen das AJAX-Partial aus (siehe
|
||||||
|
// /dashboard/tiles) und hält dabei Suchfilter, Scroll-Position und
|
||||||
|
// Sidebar-Zustand unangetastet. Läuft für an- und abgemeldete Ansicht.
|
||||||
|
let refreshTimer = null;
|
||||||
|
|
||||||
|
function scheduleNextRefresh(overrideMs) {
|
||||||
|
if (refreshTimer) clearTimeout(refreshTimer);
|
||||||
|
if (overrideMs) { refreshTimer = setTimeout(refreshDashboardTiles, overrideMs); return; }
|
||||||
|
const pill = document.getElementById("global-timer-pill");
|
||||||
|
const intervalMs = pill ? parseInt(pill.dataset.intervalMs, 10) : NaN;
|
||||||
|
if (!intervalMs) return;
|
||||||
|
const lastRunMs = parseInt(pill.dataset.lastRunMs, 10);
|
||||||
|
// + kleiner Puffer, bis poe.sh die neue Zeile tatsächlich geschrieben hat.
|
||||||
|
let waitMs = intervalMs + 1500;
|
||||||
|
if (!isNaN(lastRunMs) && lastRunMs > 0) {
|
||||||
|
const elapsed = Date.now() - lastRunMs;
|
||||||
|
waitMs = (intervalMs - (((elapsed % intervalMs) + intervalMs) % intervalMs)) + 1500;
|
||||||
|
}
|
||||||
|
refreshTimer = setTimeout(refreshDashboardTiles, waitMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshDashboardTiles() {
|
||||||
|
fetch("{{ url_for('dashboard_tiles') }}")
|
||||||
|
.then(r => r.text())
|
||||||
|
.then(html => {
|
||||||
|
const container = document.getElementById("dashboard-tiles");
|
||||||
|
if (!container) return;
|
||||||
|
const temp = document.createElement("div");
|
||||||
|
temp.innerHTML = html;
|
||||||
|
const fresh = temp.querySelector("#dashboard-tiles");
|
||||||
|
if (!fresh) return;
|
||||||
|
container.replaceWith(fresh);
|
||||||
|
// Globalen Topbar-Timer mit dem tatsächlichen Zeitpunkt
|
||||||
|
// dieses Durchlaufs synchronisieren (initCheckTimer() in
|
||||||
|
// app.js liest das Attribut bei jedem Tick neu ein).
|
||||||
|
const pill = document.getElementById("global-timer-pill");
|
||||||
|
if (pill) pill.dataset.lastRunMs = fresh.dataset.lastRunMs || "";
|
||||||
|
bindDeviceCards();
|
||||||
|
if (searchInput) applySearchFilter(searchInput.value);
|
||||||
|
scheduleNextRefresh();
|
||||||
|
})
|
||||||
|
.catch(() => scheduleNextRefresh(15000));
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNextRefresh();
|
||||||
|
document.addEventListener("poe:check-triggered", refreshDashboardTiles);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (restartButton) {
|
if (restartButton) {
|
||||||
restartButton.addEventListener("click", function () {
|
restartButton.addEventListener("click", function () {
|
||||||
@@ -295,7 +242,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
PoeUI.closeModal(document.getElementById("deviceModal"));
|
PoeUI.closeModal(document.getElementById("deviceModal"));
|
||||||
showToast(data.msg, "success");
|
showToast(data.msg, "success");
|
||||||
setTimeout(() => window.location.reload(), 500);
|
refreshDashboardTiles();
|
||||||
})
|
})
|
||||||
.catch(() => { button.disabled = false; showToast("Fehler beim Aktivieren.", "danger"); });
|
.catch(() => { button.disabled = false; showToast("Fehler beim Aktivieren.", "danger"); });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% set active_page = "logs" %}
|
{% set active_page = "logs" %}
|
||||||
{% block page_title %}Live{% endblock %}
|
{% block page_title %}Live{% endblock %}
|
||||||
{% block page_sub %}<div class="topbar-sub">{{ log_name or "kein Logfile" }}</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub" data-log-name>{{ log_name or "kein Logfile" }}</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="log-shell">
|
<div class="log-shell">
|
||||||
<div class="log-toolbar">
|
<div class="log-toolbar">
|
||||||
<div class="log-dots"><span></span><span></span><span></span></div>
|
<div class="log-dots"><span></span><span></span><span></span></div>
|
||||||
<span class="text-faint mono" style="font-size:11.5px;">{{ log_name or "" }}</span>
|
<span class="text-faint mono" style="font-size:11.5px;" data-log-name>{{ log_name or "" }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
|
<div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,7 +32,9 @@ function colorizeLine(line) {
|
|||||||
if (line.includes(" ist erreichbar!")) cls = "online";
|
if (line.includes(" ist erreichbar!")) cls = "online";
|
||||||
else if (line.includes(" ist nicht erreichbar!")) cls = "offline";
|
else if (line.includes(" ist nicht erreichbar!")) cls = "offline";
|
||||||
else if (line.startsWith("----")) cls = "sep";
|
else if (line.startsWith("----")) cls = "sep";
|
||||||
else if (line.toLowerCase().includes("manuell")) cls = "manual";
|
// Neustart-bezogene Zeilen (manueller Neustart, automatischer PoE-Restart
|
||||||
|
// bei Ausfall) einheitlich orange markieren.
|
||||||
|
else if (line.toLowerCase().includes("manuell") || line.includes("PoE")) cls = "restart";
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.className = "log-line" + (cls ? " " + cls : "");
|
span.className = "log-line" + (cls ? " " + cls : "");
|
||||||
span.textContent = line;
|
span.textContent = line;
|
||||||
@@ -43,25 +45,30 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const intervalMinutes = {{ global_check_interval | int }};
|
const intervalMinutes = {{ global_check_interval | int }};
|
||||||
const intervalMilliseconds = intervalMinutes * 60 * 1000;
|
const intervalMilliseconds = intervalMinutes * 60 * 1000;
|
||||||
|
|
||||||
function renderLog(data) {
|
function renderLog(text, logName) {
|
||||||
const box = document.getElementById("log-box");
|
const box = document.getElementById("log-box");
|
||||||
box.innerHTML = "";
|
box.innerHTML = "";
|
||||||
const lines = data.split("\n");
|
const lines = text.split("\n");
|
||||||
lines.forEach((line, i) => {
|
lines.forEach((line, i) => {
|
||||||
box.appendChild(colorizeLine(line));
|
box.appendChild(colorizeLine(line));
|
||||||
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
|
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
|
||||||
});
|
});
|
||||||
box.scrollTop = box.scrollHeight;
|
box.scrollTop = box.scrollHeight;
|
||||||
|
// Dateiname mit anzeigen — ändert sich nach einem Service-Neustart
|
||||||
|
// (neues Logfile, z.B. durch "Jetzt prüfen") ohne Seiten-Reload.
|
||||||
|
if (logName) {
|
||||||
|
document.querySelectorAll("[data-log-name]").forEach((el) => { el.textContent = logName; });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchLog() {
|
function fetchLog() {
|
||||||
fetch("{{ url_for('get_log') }}")
|
fetch("{{ url_for('get_log') }}")
|
||||||
.then(r => r.text())
|
.then(r => r.text().then((text) => renderLog(text, r.headers.get("X-Log-Name"))))
|
||||||
.then(renderLog)
|
|
||||||
.catch(err => console.error(err));
|
.catch(err => console.error(err));
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("refresh-btn").addEventListener("click", fetchLog);
|
document.getElementById("refresh-btn").addEventListener("click", fetchLog);
|
||||||
|
document.addEventListener("poe:check-triggered", fetchLog);
|
||||||
fetchLog();
|
fetchLog();
|
||||||
if (intervalMilliseconds) setInterval(fetchLog, intervalMilliseconds);
|
if (intervalMilliseconds) setInterval(fetchLog, intervalMilliseconds);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user