Dashboard-Kacheln, Ansichtsrechte, Standardgruppe, Countdown-Fix, Deploy-Pfad

- Dashboard zeigt jetzt in beiden Zustaenden (mit/ohne Login) Kacheln statt
  Tabelle; ohne Login nur aktive Geraete + Online/Offline/Gesamt, eingeloggt
  alle Geraete + zusaetzliche Deaktiviert-Kachel
- Sortierung ueberall: erst alle Nicht-Online-Geraete, dann Online, jeweils
  alphabetisch
- Neue Rechte devices.view / switches.view; Standardgruppe 'Benutzer' wird
  automatisch angelegt (alle Ansichtsrechte) und jedem neuen Benutzer
  zugeordnet; bestehende Benutzer ohne Gruppe werden migriert
- Gruppen-Seite zeigt zusaetzlich virtuelle 'Admin'-Karte (informativ) und
  markiert die Standardgruppe (nicht loeschbar)
- 'Naechste Pruefung'-Countdown wird serverseitig aus dem tatsaechlichen
  letzten Log-Eintrag geseedet statt bei jedem Reload neu zu starten
- Deployment-Doku korrigiert: Checkout getrennt von /srv/poe_manager, damit
  kein verschachteltes srv/poe_manager/srv/poe_manager entsteht

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 12:41:21 +02:00
co-authored by Claude Sonnet 5
parent 82bfeb17ed
commit 08d8d1f738
7 changed files with 241 additions and 101 deletions
+5 -1
View File
@@ -48,15 +48,19 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['grid']|safe }}</svg>
Dashboard
</a>
{% if current_user.can_view_devices %}
<a href="{{ url_for('devices') }}" class="nav-item {% if active_page == 'devices' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['cpu']|safe }}</svg>
Devices
</a>
{% if current_user.is_admin %}
{% endif %}
{% if current_user.can_manage_switches %}
<a href="{{ url_for('switches') }}" class="nav-item {% if active_page == 'switches' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['share']|safe }}</svg>
Switches
</a>
{% endif %}
{% if current_user.is_admin %}
<a href="{{ url_for('users') }}" class="nav-item {% if active_page == 'users' %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['users']|safe }}</svg>
Users
+47 -2
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% set active_page = "groups" %}
{% block page_title %}Gruppen{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ groups|length }} Gruppen · Rechteverwaltung</div>{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %}
{% block topbar_right %}
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
@@ -17,6 +17,41 @@
Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich.
</p>
{# Virtuelle Admin-"Gruppe": rein informativ, kein Formular — Admin-Status
wird ausschließlich über den is_admin-Schalter auf der Users-Seite gesetzt. #}
<div class="card card-pad" style="margin-bottom:16px; opacity:0.85;">
<div class="section-head">
<div class="flex gap-2" style="align-items:center;">
<h3 style="font-size:16px;">Admin</h3>
<span class="pill admin">Systemrolle</span>
</div>
</div>
<p class="text-faint" style="font-size:12px; margin:-8px 0 14px;">
Admins dürfen immer alles — Rolle wird über <a href="{{ url_for('users') }}" style="color:var(--accent-strong); font-weight:600;">Users</a> vergeben, nicht hier.
</p>
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px;">
{% for cat_key, cat in permission_catalog.items() %}
<div>
<div class="permission-group-title">{{ cat['label'] }}</div>
<div class="check-list">
{% for key, label in cat['items'].items() %}
<label class="check-row" style="cursor:default; color:var(--text-faint);">
<input type="checkbox" checked disabled>
{{ label }}
</label>
{% endfor %}
</div>
</div>
{% endfor %}
<div>
<div class="permission-group-title">Mitglieder</div>
<p class="text-dim" style="font-size:13px;">
{% if admin_virtual_group.member_names %}{{ admin_virtual_group.member_names|join(', ') }}{% else %}Keine Admins vorhanden.{% endif %}
</p>
</div>
</div>
</div>
{% if groups %}
<div style="display:flex; flex-direction:column; gap:16px;">
{% for g in groups %}
@@ -28,13 +63,21 @@
<div class="section-head">
<div class="field" style="margin-bottom:0; max-width:280px; flex:1;">
<label>Gruppenname</label>
<input type="text" name="name" value="{{ g.name }}" required>
<div class="flex gap-2" style="align-items:center;">
<input type="text" name="name" value="{{ g.name }}" required>
{% if g.is_default %}<span class="pill user" style="white-space:nowrap;">Standard</span>{% endif %}
</div>
</div>
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
</div>
{% if g.is_default %}
<p class="text-faint" style="font-size:11.5px; margin:-8px 0 8px;">
Standardgruppe — jeder neu angelegte Benutzer wird ihr automatisch zugeordnet. Kann nicht gelöscht werden.
</p>
{% endif %}
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px; margin-top:8px;">
{% for cat_key, cat in permission_catalog.items() %}
@@ -71,10 +114,12 @@
<span class="text-faint" style="font-size:12px;">
{% if g.member_names %}Mitglieder: {{ g.member_names|join(', ') }}{% else %}Keine Mitglieder{% endif %}
</span>
{% if not g.is_default %}
<form method="post" data-confirm="Gruppe „{{ g.name }}“ wirklich löschen? Mitglieder verlieren die zugehörigen Rechte.">
<input type="hidden" name="delete_group" value="{{ g.id }}">
<button type="submit" class="btn btn-sm btn-danger">Gruppe löschen</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
+26 -73
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% set active_page = "index" %}
{% block page_title %}Dashboard{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte im Bestand</div>{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte{{ " im Bestand" if current_user.is_authenticated else "" }}</div>{% endblock %}
{% block topbar_right %}
<span class="timer-pill"><span class="dot"></span><span id="dashboard-timer">Nächste Prüfung in --s</span></span>
{% endblock %}
@@ -17,62 +17,23 @@
<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>
{% if not current_user.is_authenticated %}
{# ============================================================ #}
{# Öffentliche, schreibgeschützte Kurzübersicht (kein Login) #}
{# ============================================================ #}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr><th>Hostname</th><th>IP-Adresse</th><th>Status</th><th>Letzte Prüfung</th></tr>
</thead>
<tbody>
{% for d in devices %}
{% set st = status.get(d['mac'], 'unbekannt') %}
<tr>
<td class="cell-name">{{ d['name'] }}</td>
<td class="mono">{{ d['rpi_ip'] }}</td>
<td>
{% if d['is_active'] == 0 %}
<span class="pill disabled">Deaktiviert</span>
{% else %}
<span class="pill {{ st }}">{{ {'online':'Online','offline':'Offline','unbekannt':'Unbekannt'}[st] }}</span>
{% endif %}
</td>
<td class="text-dim">{{ last_checked.get(d['mac']) or '—' }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="4">Noch keine Geräte vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="text-faint mt-3" style="font-size:12px;">
Nur Lese-Ansicht. <a href="{{ url_for('login') }}" style="color:var(--accent-strong); font-weight:600;">Anmelden</a>,
um Geräte zu verwalten oder einen PoE-Neustart auszulösen.
</p>
{% else %}
{# ============================================================ #}
{# Vollständiges Dashboard für eingeloggte Benutzer #}
{# ============================================================ #}
{% if devices %}
<div class="device-grid">
{% for d in devices %}
{% set st = status.get(d['mac'], 'unbekannt') %}
<div class="device-card {% if d['is_active'] == 0 %}is-disabled{% endif %}"
<div class="device-card {% if d['is_active'] == 0 %}is-disabled{% endif %} {% if not current_user.is_authenticated %}is-readonly{% endif %}"
data-status="{{ 'offline' if d['is_active'] == 0 else st }}"
data-mac="{{ d['mac'] }}"
data-name="{{ d['name'] }}"
@@ -99,10 +60,23 @@
</div>
{% 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">
@@ -159,7 +133,12 @@ document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ interval | int }};
const intervalMilliseconds = intervalMinutes * 60 * 1000;
const isAuthenticated = {{ "true" if current_user.is_authenticated else "false" }};
let lastUpdateTime = Date.now();
// Vom Server ermittelter Zeitpunkt des letzten echten Prüf-Durchlaufs
// (aus dem Logfile) — damit startet der Countdown nicht bei jedem
// Seitenaufruf wieder von vorn, sondern zeigt die tatsächlich
// verbleibende Zeit bis zur nächsten Prüfung durch poe.sh.
const lastRunAt = {{ last_run_epoch_ms | tojson }};
let lastUpdateTime = lastRunAt || Date.now();
function updateTimer() {
const now = Date.now();
@@ -171,38 +150,15 @@ document.addEventListener("DOMContentLoaded", () => {
if (remainingSec <= 1) window.location.reload();
}
setInterval(updateTimer, 1000);
updateTimer();
if (!isAuthenticated) {
// Öffentliche Ansicht: einfacher Reload-Timer, kein Zugriff auf /get_log nötig.
setInterval(updateTimer, 1000);
return;
}
let selectedCard = null, selectedMac = null, selectedName = null;
function parseLogTimestamp(ts) {
const parts = ts.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
if (!parts) return Date.now();
const [, year, month, day, hour, minute, second] = parts.map(Number);
return new Date(year, month - 1, day, hour, minute, second).getTime();
}
function fetchLastLog() {
fetch("{{ url_for('get_log') }}")
.then(r => r.text())
.then(data => {
const lines = data.split("\n");
let lastSepIndex = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].startsWith("----")) { lastSepIndex = i; break; }
}
if (lastSepIndex >= 0 && lastSepIndex + 1 < lines.length) {
const match = lines[lastSepIndex + 1].match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
if (match) lastUpdateTime = parseLogTimestamp(match[1]);
}
})
.catch(() => {});
}
document.querySelectorAll(".device-card").forEach(card => {
const mac = card.dataset.mac;
const restartKey = "restart_" + mac;
@@ -263,9 +219,6 @@ document.addEventListener("DOMContentLoaded", () => {
.catch(err => { button.disabled = false; showToast("Fehler beim Starten des Neustarts.", "danger"); });
});
}
setInterval(updateTimer, 1000);
fetchLastLog();
});
</script>
{% endblock %}