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:
2026-08-10 15:53:15 +02:00
co-authored by Claude Sonnet 5
parent 0fcb6c94cb
commit c4f682b5de
9 changed files with 438 additions and 198 deletions
+107 -160
View File
@@ -10,94 +10,7 @@
<input type="text" id="tileSearch" placeholder="Gerät suchen…">
</div>
<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 %}
{% include "_dashboard_tiles.html" %}
{% if not current_user.is_authenticated %}
<p class="text-faint mt-3" style="font-size:12px;">
@@ -167,88 +80,122 @@
{% block scripts %}
<script>
document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ global_check_interval | int }};
const intervalMilliseconds = intervalMinutes * 60 * 1000;
const isAuthenticated = {{ "true" if current_user.is_authenticated else "false" }};
// 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. Der
// 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);
let selectedCard = null, selectedMac = null, selectedName = null;
const restartButton = document.getElementById("restartButton");
const activateButton = document.getElementById("activateButton");
const searchInput = document.getElementById("tileSearch");
// Suchfilter über alle Abschnitte hinweg; Abschnitt wird komplett
// ausgeblendet, wenn keine seiner Kacheln mehr passt.
const searchInput = document.getElementById("tileSearch");
if (searchInput) {
searchInput.addEventListener("input", () => {
const q = searchInput.value.trim().toLowerCase();
let anyVisible = false;
document.querySelectorAll(".dash-section").forEach(section => {
let sectionHasMatch = false;
section.querySelectorAll(".device-card").forEach(card => {
const match = card.dataset.name.toLowerCase().includes(q);
card.classList.toggle("hidden", !match);
if (match) sectionHasMatch = true;
});
section.classList.toggle("hidden", !sectionHasMatch);
if (sectionHasMatch) anyVisible = true;
// ausgeblendet, wenn keine seiner Kacheln mehr passt. Wird nach jedem
// Live-Update erneut angewendet, damit ein aktiver Filter erhalten bleibt.
function applySearchFilter(query) {
const q = (query || "").trim().toLowerCase();
let anyVisible = false;
document.querySelectorAll(".dash-section").forEach(section => {
let sectionHasMatch = false;
section.querySelectorAll(".device-card").forEach(card => {
const match = card.dataset.name.toLowerCase().includes(q);
card.classList.toggle("hidden", !match);
if (match) sectionHasMatch = true;
});
section.classList.toggle("hidden", !sectionHasMatch);
if (sectionHasMatch) anyVisible = true;
});
const noResults = document.getElementById("noSearchResults");
if (noResults) noResults.classList.toggle("hidden", anyVisible || q === "");
}
if (searchInput) searchInput.addEventListener("input", () => applySearchFilter(searchInput.value));
// Klick-Verhalten der Kacheln (Detail-Modal, Neustart/Aktivieren) — wird
// nach jedem Live-Update erneut auf die frisch eingefügten Kacheln
// angewendet, da deren alte DOM-Knoten beim Austausch verworfen werden.
function bindDeviceCards() {
if (!isAuthenticated) return;
document.querySelectorAll(".device-card").forEach(card => {
const mac = card.dataset.mac;
const restartKey = "restart_" + mac;
if (sessionStorage.getItem(restartKey)) {
const pill = card.querySelector(".pill");
if (pill && pill.innerText.trim().toLowerCase() === "online") {
sessionStorage.removeItem(restartKey);
} else if (pill) {
pill.innerHTML = '<span class="spinner"></span> Restarting…';
}
}
card.addEventListener("click", function () {
if (sessionStorage.getItem("restart_" + this.dataset.mac)) return;
selectedCard = this;
selectedMac = this.dataset.mac;
selectedName = this.dataset.name;
const isActive = this.dataset.active != "0";
document.getElementById("deviceModalTitle").innerText = this.dataset.name;
document.getElementById("deviceIp").innerText = this.dataset.ip || "-";
document.getElementById("deviceSwitch").innerText = this.dataset.switch || "-";
document.getElementById("devicePort").innerText = this.dataset.port || "-";
document.getElementById("deviceChecked").innerText = this.dataset.checked || "-";
const pill = this.querySelector(".pill");
document.getElementById("deviceStatus").innerText = pill ? pill.innerText.trim() : "-";
if (restartButton) restartButton.style.display = isActive ? "" : "none";
if (activateButton) activateButton.style.display = isActive ? "none" : "";
PoeUI.openModal("deviceModal");
});
const noResults = document.getElementById("noSearchResults");
if (noResults) noResults.classList.toggle("hidden", anyVisible || q === "");
});
}
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;
}
let selectedCard = null, selectedMac = null, selectedName = null;
const restartButton = document.getElementById("restartButton");
const activateButton = document.getElementById("activateButton");
document.querySelectorAll(".device-card").forEach(card => {
const mac = card.dataset.mac;
const restartKey = "restart_" + mac;
if (sessionStorage.getItem(restartKey)) {
const pill = card.querySelector(".pill");
if (pill && pill.innerText.trim().toLowerCase() === "online") {
sessionStorage.removeItem(restartKey);
} else if (pill) {
pill.innerHTML = '<span class="spinner"></span> Restarting…';
}
}
card.addEventListener("click", function () {
if (sessionStorage.getItem("restart_" + this.dataset.mac)) return;
selectedCard = this;
selectedMac = this.dataset.mac;
selectedName = this.dataset.name;
const isActive = this.dataset.active != "0";
document.getElementById("deviceModalTitle").innerText = this.dataset.name;
document.getElementById("deviceIp").innerText = this.dataset.ip || "-";
document.getElementById("deviceSwitch").innerText = this.dataset.switch || "-";
document.getElementById("devicePort").innerText = this.dataset.port || "-";
document.getElementById("deviceChecked").innerText = this.dataset.checked || "-";
const pill = this.querySelector(".pill");
document.getElementById("deviceStatus").innerText = pill ? pill.innerText.trim() : "-";
if (restartButton) restartButton.style.display = isActive ? "" : "none";
if (activateButton) activateButton.style.display = isActive ? "none" : "";
PoeUI.openModal("deviceModal");
});
});
if (restartButton) {
restartButton.addEventListener("click", function () {
PoeUI.closeModal(document.getElementById("deviceModal"));
@@ -295,7 +242,7 @@ document.addEventListener("DOMContentLoaded", () => {
}
PoeUI.closeModal(document.getElementById("deviceModal"));
showToast(data.msg, "success");
setTimeout(() => window.location.reload(), 500);
refreshDashboardTiles();
})
.catch(() => { button.disabled = false; showToast("Fehler beim Aktivieren.", "danger"); });
});