Topbar auf globalen Prüf-Timer reduziert, Aktionen+Beschreibung über Tabellen, sortierbare Listen

- Topbar zeigt rechts jetzt ausschließlich den "Nächste Prüfung"-Countdown,
  konsistent auf jeder Seite (auch anonymes Dashboard) statt nur auf dem
  Dashboard. Neuer Context-Processor inject_check_timer()/get_last_run_at()
  liefert last_run/interval global, ohne dass jede Route das selbst
  berechnen muss.
- "+ Neu ..."-Buttons (Devices, Switches, Zugangsdaten, Benutzer, Gruppen)
  aus der Topbar entfernt und stattdessen in einen .section-head direkt
  über der jeweiligen Tabelle verschoben, zusammen mit einer kurzen
  Beschreibung der Seite (bisher nur bei Zugangsdaten/Gruppen vorhanden,
  jetzt auch bei Geräte/Switche/Benutzer).
- Live-Log: eigene lokale Timer-Pill entfernt (redundant zum globalen
  Timer), "Aktualisieren"-Button in denselben section-head verschoben.
  Dashboard: Suchfeld aus der Topbar in den Seiteninhalt verschoben, lokale
  Timer-Anzeige entfernt (übernimmt die globale Topbar-Pill), Reload-bei-
  Intervallende-Logik bleibt als separater, unsichtbarer Scheduler erhalten.
- Neue generische Tabellen-Sortierung (app.js: initSortableTables): Klick
  auf eine Spaltenüberschrift mit data-sort-key sortiert die Zeilen anhand
  von data-sort-<key>-Attributen. Unterstützt auch Akkordeon-Tabellen mit
  mehreren <tbody> (Gruppen: Haupt- + Detail-Zeile bleiben als Einheit
  zusammen, die virtuelle "Admin"-Zeile bleibt über data-sort-pinned immer
  oben). Angewendet auf Geräte, Switche, Zugangsdaten, Benutzer, Gruppen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 15:21:30 +02:00
co-authored by Claude Sonnet 5
parent e4cceb4091
commit 0fcb6c94cb
12 changed files with 287 additions and 127 deletions
+5 -1
View File
@@ -112,7 +112,11 @@
</div>
<div class="topbar-right">
{% block topbar_right %}{% endblock %}
<span class="timer-pill" id="global-timer-pill"
data-last-run-ms="{{ global_last_run_epoch_ms or '' }}"
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>
{% if not current_user.is_authenticated %}
<a href="{{ url_for('login') }}" 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="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/></svg>
+23 -15
View File
@@ -5,29 +5,37 @@
{% set can_delete = current_user.has_permission('switches.delete') %}
{% block page_title %}Zugangsdaten{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %}
{% block topbar_right %}
{% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="addCredentialModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neue Zugangsdaten
</button>
{% endif %}
{% endblock %}
{% block content %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px; max-width:720px;">
Zugangsdaten können mehreren Switchen gleichzeitig zugeordnet werden. Beim Anlegen eines
Switches lassen sich bestehende Zugangsdaten auswählen oder direkt neue hinterlegen.
</p>
<div class="section-head">
<div>
<h2 style="font-size:16px;">Zugangsdaten</h2>
<div class="hint">
Zugangsdaten können mehreren Switchen gleichzeitig zugeordnet werden. Beim Anlegen eines
Switches lassen sich bestehende Zugangsdaten auswählen oder direkt neue hinterlegen.
</div>
</div>
{% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="addCredentialModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neue Zugangsdaten
</button>
{% endif %}
</div>
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Name</th><th>Username</th><th>Verwendet von</th><th style="width:1%;">Aktionen</th></tr></thead>
<table class="data-table" data-sortable>
<thead><tr>
<th data-sort-key="name">Name</th>
<th data-sort-key="username">Username</th>
<th data-sort-key="usage">Verwendet von</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<tbody>
{% for c in credentials %}
<tr>
<tr data-sort-name="{{ c['name']|lower }}" data-sort-username="{{ c['username']|lower }}" data-sort-usage="{{ c['switch_count'] }}">
<td class="cell-name">{{ c['name'] }}</td>
<td class="mono">{{ c['username'] }}</td>
<td class="text-dim">{{ c['switch_count'] }} Switch{{ 'e' if c['switch_count'] != 1 else '' }}</td>
+20 -15
View File
@@ -8,17 +8,22 @@
{% set col_count = 5 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %}
{% block page_title %}Clients{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
{% block topbar_right %}
{% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="deviceModal" onclick="resetDeviceForm()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neues Gerät
</button>
{% endif %}
{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Geräte</h2>
<div class="hint">PoE-Geräte mit IP, MAC-Adresse und zugeordnetem Switch-Port für die automatische Erreichbarkeitsprüfung.</div>
</div>
{% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="deviceModal" onclick="resetDeviceForm()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neues Gerät
</button>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
@@ -27,21 +32,21 @@
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="devicesTable">
<table class="data-table" id="devicesTable" data-sortable>
<thead>
<tr>
<th>Hostname</th>
<th>IP-Adresse</th>
<th>MAC-Adresse</th>
<th>Switch</th>
<th>Port</th>
<th data-sort-key="hostname">Hostname</th>
<th data-sort-key="ip">IP-Adresse</th>
<th data-sort-key="mac">MAC-Adresse</th>
<th data-sort-key="switch">Switch</th>
<th data-sort-key="port">Port</th>
{% if can_toggle %}<th>Status</th>{% endif %}
{% if show_actions_col %}<th style="width:1%;">Aktionen</th>{% endif %}
</tr>
</thead>
<tbody>
{% for d in devices %}
<tr>
<tr data-sort-hostname="{{ d['name']|lower }}" data-sort-ip="{{ d['rpi_ip']|lower }}" data-sort-mac="{{ d['mac']|lower }}" data-sort-switch="{{ (d['switch_hostname'] or '')|lower }}" data-sort-port="{{ (d['port'] or '')|lower }}">
<td class="cell-name">{{ d['name'] }}</td>
<td class="mono">{{ d['rpi_ip'] }}</td>
<td class="mono">{{ d['mac'] }}</td>
+35 -20
View File
@@ -2,28 +2,37 @@
{% set active_page = "groups" %}
{% block page_title %}Gruppen{% 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>
Neue Gruppe
</button>
{% endblock %}
{% block content %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px; max-width:720px;">
Über Gruppen lassen sich einzelne Verwaltungsrechte für Devices und Switches gezielt vergeben.
Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich. Auf „Rechte“ klicken,
um eine Gruppe aufzuklappen und die Berechtigungen im Detail zu sehen bzw. zu ändern.
</p>
<div class="section-head">
<div>
<h2 style="font-size:16px;">Gruppen</h2>
<div class="hint">
Über Gruppen lassen sich einzelne Verwaltungsrechte für Devices und Switches gezielt vergeben.
Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich. Auf „Rechte“ klicken,
um eine Gruppe aufzuklappen und die Berechtigungen im Detail zu sehen bzw. zu ändern.
</div>
</div>
<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>
Neue Gruppe
</button>
</div>
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Gruppe</th><th>Mitglieder</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
<!-- Virtuelle "Admin"-Gruppe: Rechte sind fix (alles), Mitgliedschaft
wird direkt über is_admin gesteuert. -->
<table class="data-table" data-sortable>
<thead><tr>
<th data-sort-key="name">Gruppe</th>
<th data-sort-key="members">Mitglieder</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<!-- Virtuelle "Admin"-Gruppe: Rechte sind fix (alles), Mitgliedschaft
wird direkt über is_admin gesteuert. Bleibt beim Sortieren immer
oben (eigenes <tbody data-sort-pinned>). -->
<tbody data-sort-pinned>
<tr>
<td class="cell-name">Admin <span class="pill admin">Systemrolle</span></td>
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
@@ -58,8 +67,12 @@
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
</td>
</tr>
</tbody>
{% for g in groups %}
{% for g in groups %}
<!-- Jede Gruppe (Haupt- + Detail-Zeile) in einem eigenen <tbody>, damit
die Sortierung beide Zeilen gemeinsam verschiebt. -->
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
<tr>
<td class="cell-name">
{{ g.name }}
@@ -116,10 +129,12 @@
</form>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="3">Noch keine weiteren Gruppen angelegt.</td></tr>
{% endfor %}
</tbody>
{% else %}
<tbody data-sort-pinned>
<tr class="empty-row"><td colspan="3">Noch keine weiteren Gruppen angelegt.</td></tr>
</tbody>
{% endfor %}
</table>
</div>
</div>
+13 -16
View File
@@ -2,15 +2,13 @@
{% 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;">
{% block content %}
<div class="search-input" style="max-width:320px; margin-bottom:18px;">
<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">
@@ -169,28 +167,27 @@
{% block scripts %}
<script>
document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ interval | int }};
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.
const lastRunAt = {{ last_run_epoch_ms | tojson }};
// 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 updateTimer() {
function checkForReload() {
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();
if (Math.ceil(remainingMs / 1000) <= 1) window.location.reload();
}
setInterval(updateTimer, 1000);
updateTimer();
if (intervalMilliseconds) setInterval(checkForReload, 1000);
// Suchfilter über alle Abschnitte hinweg; Abschnitt wird komplett
// ausgeblendet, wenn keine seiner Kacheln mehr passt.
+14 -36
View File
@@ -2,15 +2,20 @@
{% set active_page = "logs" %}
{% block page_title %}Live{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ log_name or "kein Logfile" }}</div>{% endblock %}
{% block topbar_right %}
<span class="timer-pill"><span class="dot"></span>Update in <span id="timer">--</span>s</span>
<button id="refresh-btn" class="btn btn-secondary">
<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>
Aktualisieren
</button>
{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Live-Log</h2>
<div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline).</div>
</div>
<button id="refresh-btn" class="btn btn-secondary">
<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>
Aktualisieren
</button>
</div>
<div class="log-shell">
<div class="log-toolbar">
<div class="log-dots"><span></span><span></span><span></span></div>
@@ -22,13 +27,6 @@
{% block scripts %}
<script>
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 colorizeLine(line) {
let cls = "";
if (line.includes(" ist erreichbar!")) cls = "online";
@@ -42,9 +40,8 @@ function colorizeLine(line) {
}
document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ interval | int }};
const intervalMinutes = {{ global_check_interval | int }};
const intervalMilliseconds = intervalMinutes * 60 * 1000;
let lastUpdateTime = Date.now();
function renderLog(data) {
const box = document.getElementById("log-box");
@@ -55,17 +52,6 @@ document.addEventListener("DOMContentLoaded", () => {
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
});
box.scrollTop = box.scrollHeight;
let lastSep = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].startsWith("----")) { lastSep = i; break; }
}
if (lastSep >= 0 && lastSep + 1 < lines.length) {
const match = lines[lastSep + 1].match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/);
lastUpdateTime = match ? parseLogTimestamp(match[1]) : Date.now();
} else {
lastUpdateTime = Date.now();
}
}
function fetchLog() {
@@ -75,17 +61,9 @@ document.addEventListener("DOMContentLoaded", () => {
.catch(err => console.error(err));
}
function updateTimer() {
const now = Date.now();
const elapsed = now - lastUpdateTime;
const remainingMs = intervalMilliseconds - (elapsed % intervalMilliseconds);
document.getElementById("timer").innerText = Math.ceil(remainingMs / 1000);
}
document.getElementById("refresh-btn").addEventListener("click", fetchLog);
fetchLog();
setInterval(fetchLog, intervalMilliseconds);
setInterval(updateTimer, 1000);
if (intervalMilliseconds) setInterval(fetchLog, intervalMilliseconds);
});
</script>
{% endblock %}
+21 -11
View File
@@ -8,17 +8,22 @@
{% set can_delete = current_user.has_permission('switches.delete') %}
{% block page_title %}Switche{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
{% block topbar_right %}
{% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="addSwitchModal" onclick="resetCredentialChoice('add')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neuer Switch
</button>
{% endif %}
{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Switche</h2>
<div class="hint">Aruba-Switche mit ihren Zugangsdaten, über die der automatische PoE-Neustart bei Ausfällen sowie der Verbindungstest laufen.</div>
</div>
{% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="addSwitchModal" onclick="resetCredentialChoice('add')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neuer Switch
</button>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
@@ -27,13 +32,18 @@
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="switchesTable">
<table class="data-table" id="switchesTable" data-sortable>
<thead>
<tr><th>Hostname</th><th>IP-Adresse</th><th>Zugangsdaten</th><th style="width:1%;">Aktionen</th></tr>
<tr>
<th data-sort-key="hostname">Hostname</th>
<th data-sort-key="ip">IP-Adresse</th>
<th data-sort-key="credential">Zugangsdaten</th>
<th style="width:1%;">Aktionen</th>
</tr>
</thead>
<tbody>
{% for s in switches %}
<tr>
<tr data-sort-hostname="{{ s['hostname']|lower }}" data-sort-ip="{{ s['ip']|lower }}" data-sort-credential="{{ (s['credential_name'] or '')|lower }}">
<td class="cell-name">{{ s['hostname'] }}</td>
<td class="mono">{{ s['ip'] }}</td>
<td>
+22 -12
View File
@@ -2,26 +2,36 @@
{% set active_page = "users" %}
{% block page_title %}Benutzer{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
{% block topbar_right %}
<button type="button" class="btn btn-primary" data-open-modal="userModal" onclick="document.getElementById('userForm').reset();">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neuer Benutzer
</button>
{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Benutzer</h2>
<div class="hint">Benutzerkonten mit Zugriff auf den PoE Manager. Die Gruppe bestimmt, welche Verwaltungsrechte ein Benutzer hat.</div>
</div>
<button type="button" class="btn btn-primary" data-open-modal="userModal" onclick="document.getElementById('userForm').reset();">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neuer Benutzer
</button>
</div>
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Username</th><th>Name</th><th>Gruppe</th><th style="width:1%;">Aktionen</th></tr></thead>
<table class="data-table" data-sortable>
<thead><tr>
<th data-sort-key="username">Username</th>
<th data-sort-key="name">Name</th>
<th data-sort-key="group">Gruppe</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<tbody>
{% for u in users %}
<tr>
{% set full_name = [u['first_name'], u['last_name']]|select|join(' ') %}
{% set group_label = 'Admin' if u['is_admin'] else (u['group_names'] or '') %}
<tr data-sort-username="{{ u['username']|lower }}" data-sort-name="{{ full_name|lower }}" data-sort-group="{{ group_label|lower }}">
<td class="cell-name">{{ u['username'] }}</td>
<td class="text-dim">
{% if u['first_name'] or u['last_name'] %}{{ [u['first_name'], u['last_name']]|select|join(' ') }}{% else %}—{% endif %}
</td>
<td class="text-dim">{{ full_name or '—' }}</td>
<td>
{% if u['is_admin'] %}
<span class="pill admin">Admin</span>