Files
tesm/srv/poe_manager/templates/index.html
T
alientimandClaude Sonnet 5 fa6bb2935f Zugangsdaten-Entitaet, Gruppen-Tabelle mit Aufklappansicht, Vor-/Nachname, Dashboard-Sektionen
- Zugangsdaten (SSH-Logins) als eigenstaendige, wiederverwendbare Entitaet
  statt direkt am Switch; inline Neuanlage beim Switch-Erstellen moeglich;
  automatische Migration bestehender Switch-Logins
- Gruppen-Seite als Tabelle mit Aufklapp-Zeile fuer Rechte (Akkordeon),
  Admin als feste Systemzeile (Mitgliederverwaltung ueber is_admin),
  Standardgruppe 'Benutzer' mit allen Ansichtsrechten (devices.view,
  switches.view), automatische Zuordnung neuer/verwaister Benutzer
- Users-Seite: Icon-Buttons statt Text, Bearbeiten+Passwortaenderung in
  einem Modal zusammengefuehrt, Gruppe/Admin-Zuweisung ueber eigenen
  Zuweisen-Button (wie Switch-Zuordnung bei Devices), Vor-/Nachname
- Dashboard: einheitliche Kachelansicht mit/ohne Login, drei sortierte
  Abschnitte (Offline/Online/Deaktiviert), Kachel-Suchfilter, Bootstrap-
  artiges Grid (max. 6 Spalten), Aktivieren-Option im Popup fuer
  deaktivierte Geraete, Countdown serverseitig korrekt geseedet
- Sidebar dauerhaft einklappbar (Desktop, persistent via localStorage)
- Devices-Tabelle: Aktions-Buttons nebeneinander statt untereinander

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 13:30:47 +02:00

309 lines
14 KiB
HTML

{% extends "base.html" %}
{% set active_page = "index" %}
{% block page_title %}Dashboard{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ device_count }} Geräte{{ " im Bestand" if current_user.is_authenticated else "" }}</div>{% endblock %}
{% block topbar_right %}
<div class="search-input" style="min-width:180px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="tileSearch" placeholder="Gerät suchen…">
</div>
<span class="timer-pill"><span class="dot"></span><span id="dashboard-timer">Nächste Prüfung in --s</span></span>
{% endblock %}
{% block content %}
<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="{{ 'offline' 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">Aus</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 %}
<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 -->
<div class="modal-overlay" id="deviceModal">
<div class="modal">
<div class="modal-header">
<h3 id="deviceModalTitle">Gerät</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="detail-list">
<div class="detail-row"><span class="k">IP-Adresse</span><span class="v mono" id="deviceIp">-</span></div>
<div class="detail-row"><span class="k">Switch</span><span class="v" id="deviceSwitch">-</span></div>
<div class="detail-row"><span class="k">Port</span><span class="v" id="devicePort">-</span></div>
<div class="detail-row"><span class="k">Status</span><span class="v" id="deviceStatus">-</span></div>
<div class="detail-row"><span class="k">Letzte Prüfung</span><span class="v" id="deviceChecked">-</span></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
{% if current_user.has_permission('devices.toggle') %}
<button type="button" class="btn btn-success" id="activateButton" style="display:none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L20 7"/></svg>
Aktivieren
</button>
{% endif %}
{% if current_user.has_permission('devices.restart') %}
<button type="button" class="btn btn-primary" id="restartButton" style="display:none;">
<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>
Neustarten
</button>
{% endif %}
</div>
</div>
</div>
{% if current_user.has_permission('devices.restart') %}
<!-- Restart confirm modal -->
<div class="modal-overlay" id="restartModal">
<div class="modal" style="max-width:400px;">
<div class="modal-header">
<h3 id="restartTitle">Neustart</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p class="text-dim">Der Neustart erfolgt durch einen kurzen PoE-Reset. Das Gerät wird für wenige Sekunden vom Netzwerk getrennt.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="button" class="btn btn-primary" id="confirmRestart" style="background:var(--warning); color:#241a00;">Neustarten</button>
</div>
</div>
</div>
{% endif %}
{% endif %}
{% endblock %}
{% block scripts %}
<script>
document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ 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.
const lastRunAt = {{ last_run_epoch_ms | tojson }};
let lastUpdateTime = lastRunAt || Date.now();
function updateTimer() {
const now = Date.now();
const elapsed = now - lastUpdateTime;
const remainingMs = intervalMilliseconds - (elapsed % intervalMilliseconds);
const remainingSec = Math.ceil(remainingMs / 1000);
const el = document.getElementById("dashboard-timer");
if (el) el.innerText = `Nächste Prüfung in ${remainingSec}s`;
if (remainingSec <= 1) window.location.reload();
}
setInterval(updateTimer, 1000);
updateTimer();
// 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;
});
const noResults = document.getElementById("noSearchResults");
if (noResults) noResults.classList.toggle("hidden", anyVisible || q === "");
});
}
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"));
document.getElementById("restartTitle").innerText = selectedName + " neu starten?";
PoeUI.openModal("restartModal");
});
}
const confirmRestart = document.getElementById("confirmRestart");
if (confirmRestart) {
confirmRestart.addEventListener("click", function () {
const button = this;
button.disabled = true;
fetch("/restart/" + encodeURIComponent(selectedMac), { method: "POST" })
.then(r => r.json())
.then(data => {
button.disabled = false;
if (!data.success) {
showToast(data.message || "Neustart konnte nicht gestartet werden.", "danger");
return;
}
PoeUI.closeModal(document.getElementById("restartModal"));
PoeUI.closeModal(document.getElementById("deviceModal"));
sessionStorage.setItem("restart_" + selectedMac, "1");
const pill = selectedCard.querySelector(".pill");
if (pill) pill.innerHTML = '<span class="spinner"></span> Restarting…';
showToast(`Neustart von ${data.device} gestartet.`, "success");
})
.catch(err => { button.disabled = false; showToast("Fehler beim Starten des Neustarts.", "danger"); });
});
}
if (activateButton) {
activateButton.addEventListener("click", function () {
const button = this;
button.disabled = true;
fetch("/devices/toggle/" + encodeURIComponent(selectedMac), { method: "POST" })
.then(r => r.json())
.then(data => {
button.disabled = false;
if (!data.success) {
showToast(data.msg || "Aktivieren fehlgeschlagen.", "danger");
return;
}
PoeUI.closeModal(document.getElementById("deviceModal"));
showToast(data.msg, "success");
setTimeout(() => window.location.reload(), 500);
})
.catch(() => { button.disabled = false; showToast("Fehler beim Aktivieren.", "danger"); });
});
}
});
</script>
{% endblock %}