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
+7 -1
View File
@@ -29,8 +29,14 @@ Die App ermöglicht:
Geräten bietet das Detail-Popup stattdessen direkt eine Aktivieren-Option Geräten bietet das Detail-Popup stattdessen direkt eine Aktivieren-Option
- Einstellbares Prüfintervall (steuert den Hintergrund-Check-Dienst) — - Einstellbares Prüfintervall (steuert den Hintergrund-Check-Dienst) —
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 Log-Eintrag berechnet, nicht bei jedem Seitenaufruf neu gestartet, und
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)
- **Einheitliches Seiten-Layout**: die Topbar zeigt rechts ausschließlich
den „Nächste Prüfung“-Timer; Anlegen-Buttons und eine kurze Beschreibung
sitzen stattdessen direkt über der jeweiligen Tabelle. Die Listen
(Geräte, Switche, Zugangsdaten, Benutzer, Gruppen) sind per Klick auf die
Spaltenüberschrift sortierbar
- **Rechtesystem mit Gruppen**: Admins dürfen immer alles (als Gruppe „Admin“ - **Rechtesystem mit Gruppen**: Admins dürfen immer alles (als Gruppe „Admin“
überall mit aufgeführt); normalen Benutzern lassen sich über Gruppen überall mit aufgeführt); normalen Benutzern lassen sich über Gruppen
einzelne Verwaltungsrechte gezielt zuweisen, inkl. reiner Ansichtsrechte einzelne Verwaltungsrechte gezielt zuweisen, inkl. reiner Ansichtsrechte
+34
View File
@@ -651,6 +651,40 @@ def _format_ts(ts_str: str) -> str:
return ts_str return ts_str
def get_last_run_at():
"""Zeitpunkt des letzten Prüf-Durchlaufs (jüngste Zeile im aktuellsten
Logfile) — Basis für den globalen 'Nächste Prüfung'-Countdown in der
Topbar, unabhängig von einer bestimmten Geräteliste."""
latest_log = _latest_log_file()
if not latest_log:
return None
latest_ts_str = None
with open(latest_log, "r") as f:
for line in f:
m = _LOG_LINE_RE.match(line.strip())
if m and (latest_ts_str is None or m.group(1) > latest_ts_str):
latest_ts_str = m.group(1)
if not latest_ts_str:
return None
try:
return datetime.strptime(latest_ts_str, "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
@app.context_processor
def inject_check_timer():
"""Stellt den 'Nächste Prüfung'-Countdown global für die Topbar bereit
(auf jeder Seite sichtbar, siehe base.html) — unabhängig davon, ob die
jeweilige Route selbst etwas mit Geräten/Logs zu tun hat."""
interval = int(get_setting("interval", 5))
last_run_at = get_last_run_at()
return {
"global_check_interval": interval,
"global_last_run_epoch_ms": int(last_run_at.timestamp() * 1000) if last_run_at else None,
}
def get_device_status(devices): def get_device_status(devices):
""" """
Ermittelt aus dem aktuellsten Logfile in einem Durchlauf je Gerät: Ermittelt aus dem aktuellsten Logfile in einem Durchlauf je Gerät:
+16
View File
@@ -671,6 +671,22 @@ table.data-table {
font-size: 13.5px; font-size: 13.5px;
} }
.data-table thead th.sortable-col {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.data-table thead th.sortable-col:hover { color: var(--text); }
.data-table thead th.sortable-col::after {
content: "⇅";
display: inline-block;
margin-left: 5px;
font-size: 10px;
opacity: 0.35;
}
.data-table thead th.sortable-col.sort-asc::after { content: "▲"; opacity: 0.85; }
.data-table thead th.sortable-col.sort-desc::after { content: "▼"; opacity: 0.85; }
.data-table thead th { .data-table thead th {
text-align: left; text-align: left;
font-size: 11px; font-size: 11px;
+77
View File
@@ -215,6 +215,81 @@
}); });
} }
/* ---------------- Globaler "Nächste Prüfung"-Timer (Topbar, jede Seite) ---------------- */
function initCheckTimer() {
const pill = document.getElementById("global-timer-pill");
const timerEl = document.getElementById("global-timer");
if (!pill || !timerEl) return;
const lastRunMs = parseInt(pill.dataset.lastRunMs, 10);
const intervalMs = parseInt(pill.dataset.intervalMs, 10);
if (!intervalMs) { timerEl.textContent = "--"; return; }
function update() {
let remainingMs = intervalMs;
if (!isNaN(lastRunMs) && lastRunMs > 0) {
const elapsed = Date.now() - lastRunMs;
remainingMs = intervalMs - (((elapsed % intervalMs) + intervalMs) % intervalMs);
}
timerEl.textContent = Math.max(0, Math.ceil(remainingMs / 1000));
}
update();
setInterval(update, 1000);
}
/* ---------------- Sortierbare Tabellen ---------------- */
/*
* Klick auf <th data-sort-key="..."> sortiert die Tabelle. Zwei Modi:
* - Normale Tabellen: sortiert <tr>-Zeilen innerhalb des einzigen <tbody>
* anhand von data-sort-<key> auf der jeweiligen <tr>.
* - Akkordeon-Tabellen (mehrere <tbody>, z.B. Gruppen mit Detail-Zeile):
* sortiert ganze <tbody>-Blöcke anhand von data-sort-<key> auf dem
* jeweiligen <tbody>, damit Haupt- und Detail-Zeile zusammenbleiben.
* <tbody data-sort-pinned> (z.B. die virtuelle "Admin"-Zeile) bleibt
* dabei immer an ihrer Position.
*/
function compareSortValues(a, b, asc) {
a = (a === null || a === undefined) ? "" : String(a);
b = (b === null || b === undefined) ? "" : String(b);
const na = parseFloat(a), nb = parseFloat(b);
const bothNumeric = a !== "" && b !== "" && !isNaN(na) && !isNaN(nb);
const cmp = bothNumeric ? (na - nb) : a.toLowerCase().localeCompare(b.toLowerCase(), "de");
return asc ? cmp : -cmp;
}
function sortTable(table, th) {
const key = th.dataset.sortKey;
const asc = th.dataset.sortDir !== "asc";
table.querySelectorAll("thead th[data-sort-key]").forEach((h) => {
delete h.dataset.sortDir;
h.classList.remove("sort-asc", "sort-desc");
});
th.dataset.sortDir = asc ? "asc" : "desc";
th.classList.add(asc ? "sort-asc" : "sort-desc");
if (table.tBodies.length > 1) {
const movable = Array.prototype.filter.call(table.tBodies, (tb) => !tb.hasAttribute("data-sort-pinned"));
movable.sort((a, b) => compareSortValues(
a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc
));
movable.forEach((tb) => table.appendChild(tb));
} else {
const tbody = table.tBodies[0];
const rows = Array.prototype.filter.call(tbody.rows, (r) => !r.classList.contains("empty-row"));
rows.sort((a, b) => compareSortValues(
a.getAttribute("data-sort-" + key), b.getAttribute("data-sort-" + key), asc
));
rows.forEach((r) => tbody.appendChild(r));
}
}
function initSortableTables() {
document.querySelectorAll("table[data-sortable] thead th[data-sort-key]").forEach((th) => {
th.classList.add("sortable-col");
th.addEventListener("click", () => sortTable(th.closest("table"), th));
});
}
/* ---------------- Init ---------------- */ /* ---------------- Init ---------------- */
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
@@ -224,6 +299,8 @@
initConfirmables(); initConfirmables();
initFlashedMessages(); initFlashedMessages();
initNavGroups(); initNavGroups();
initCheckTimer();
initSortableTables();
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
}); });
+5 -1
View File
@@ -112,7 +112,11 @@
</div> </div>
<div class="topbar-right"> <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 %} {% if not current_user.is_authenticated %}
<a href="{{ url_for('login') }}" class="btn btn-primary"> <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> <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>
+21 -13
View File
@@ -5,29 +5,37 @@
{% set can_delete = current_user.has_permission('switches.delete') %} {% set can_delete = current_user.has_permission('switches.delete') %}
{% block page_title %}Zugangsdaten{% endblock %} {% block page_title %}Zugangsdaten{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% 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 %} {% block content %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px; max-width:720px;"> <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 Zugangsdaten können mehreren Switchen gleichzeitig zugeordnet werden. Beim Anlegen eines
Switches lassen sich bestehende Zugangsdaten auswählen oder direkt neue hinterlegen. Switches lassen sich bestehende Zugangsdaten auswählen oder direkt neue hinterlegen.
</p> </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 class="table-wrap">
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<table class="data-table"> <table class="data-table" data-sortable>
<thead><tr><th>Name</th><th>Username</th><th>Verwendet von</th><th style="width:1%;">Aktionen</th></tr></thead> <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> <tbody>
{% for c in credentials %} {% 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="cell-name">{{ c['name'] }}</td>
<td class="mono">{{ c['username'] }}</td> <td class="mono">{{ c['username'] }}</td>
<td class="text-dim">{{ c['switch_count'] }} Switch{{ 'e' if c['switch_count'] != 1 else '' }}</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) %} {% set col_count = 5 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %}
{% block page_title %}Clients{% endblock %} {% block page_title %}Clients{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% 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 %} {% 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-wrap">
<div class="table-toolbar"> <div class="table-toolbar">
<div class="search-input"> <div class="search-input">
@@ -27,21 +32,21 @@
</div> </div>
</div> </div>
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<table class="data-table" id="devicesTable"> <table class="data-table" id="devicesTable" data-sortable>
<thead> <thead>
<tr> <tr>
<th>Hostname</th> <th data-sort-key="hostname">Hostname</th>
<th>IP-Adresse</th> <th data-sort-key="ip">IP-Adresse</th>
<th>MAC-Adresse</th> <th data-sort-key="mac">MAC-Adresse</th>
<th>Switch</th> <th data-sort-key="switch">Switch</th>
<th>Port</th> <th data-sort-key="port">Port</th>
{% if can_toggle %}<th>Status</th>{% endif %} {% if can_toggle %}<th>Status</th>{% endif %}
{% if show_actions_col %}<th style="width:1%;">Aktionen</th>{% endif %} {% if show_actions_col %}<th style="width:1%;">Aktionen</th>{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for d in devices %} {% 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="cell-name">{{ d['name'] }}</td>
<td class="mono">{{ d['rpi_ip'] }}</td> <td class="mono">{{ d['rpi_ip'] }}</td>
<td class="mono">{{ d['mac'] }}</td> <td class="mono">{{ d['mac'] }}</td>
+30 -15
View File
@@ -2,28 +2,37 @@
{% set active_page = "groups" %} {% set active_page = "groups" %}
{% block page_title %}Gruppen{% endblock %} {% block page_title %}Gruppen{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} 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>
Neue Gruppe
</button>
{% endblock %}
{% block content %} {% block content %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px; max-width:720px;"> <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. Ü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, 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. um eine Gruppe aufzuklappen und die Berechtigungen im Detail zu sehen bzw. zu ändern.
</p> </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 class="table-wrap">
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<table class="data-table"> <table class="data-table" data-sortable>
<thead><tr><th>Gruppe</th><th>Mitglieder</th><th style="width:1%;">Aktionen</th></tr></thead> <thead><tr>
<tbody> <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 <!-- Virtuelle "Admin"-Gruppe: Rechte sind fix (alles), Mitgliedschaft
wird direkt über is_admin gesteuert. --> wird direkt über is_admin gesteuert. Bleibt beim Sortieren immer
oben (eigenes <tbody data-sort-pinned>). -->
<tbody data-sort-pinned>
<tr> <tr>
<td class="cell-name">Admin <span class="pill admin">Systemrolle</span></td> <td class="cell-name">Admin <span class="pill admin">Systemrolle</span></td>
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</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> <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> </td>
</tr> </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> <tr>
<td class="cell-name"> <td class="cell-name">
{{ g.name }} {{ g.name }}
@@ -116,10 +129,12 @@
</form> </form>
</td> </td>
</tr> </tr>
{% else %}
<tr class="empty-row"><td colspan="3">Noch keine weiteren Gruppen angelegt.</td></tr>
{% endfor %}
</tbody> </tbody>
{% else %}
<tbody data-sort-pinned>
<tr class="empty-row"><td colspan="3">Noch keine weiteren Gruppen angelegt.</td></tr>
</tbody>
{% endfor %}
</table> </table>
</div> </div>
</div> </div>
+13 -16
View File
@@ -2,15 +2,13 @@
{% set active_page = "index" %} {% set active_page = "index" %}
{% block page_title %}Dashboard{% endblock %} {% 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 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> <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…"> <input type="text" id="tileSearch" placeholder="Gerät suchen…">
</div> </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-row">
<div class="stat-card"> <div class="stat-card">
@@ -169,28 +167,27 @@
{% block scripts %} {% block scripts %}
<script> <script>
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ interval | int }}; const intervalMinutes = {{ global_check_interval | int }};
const intervalMilliseconds = intervalMinutes * 60 * 1000; 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 // Vom Server ermittelter Zeitpunkt des letzten echten Prüf-Durchlaufs
// (aus dem Logfile) — damit startet der Countdown nicht bei jedem // (aus dem Logfile) — damit startet der Countdown nicht bei jedem
// Seitenaufruf wieder von vorn, sondern zeigt die tatsächlich // Seitenaufruf wieder von vorn, sondern zeigt die tatsächlich
// verbleibende Zeit bis zur nächsten Prüfung durch poe.sh. // verbleibende Zeit bis zur nächsten Prüfung durch poe.sh. Der
const lastRunAt = {{ last_run_epoch_ms | tojson }}; // 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(); let lastUpdateTime = lastRunAt || Date.now();
function updateTimer() { function checkForReload() {
const now = Date.now(); const now = Date.now();
const elapsed = now - lastUpdateTime; const elapsed = now - lastUpdateTime;
const remainingMs = intervalMilliseconds - (elapsed % intervalMilliseconds); const remainingMs = intervalMilliseconds - (elapsed % intervalMilliseconds);
const remainingSec = Math.ceil(remainingMs / 1000); if (Math.ceil(remainingMs / 1000) <= 1) window.location.reload();
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); if (intervalMilliseconds) setInterval(checkForReload, 1000);
updateTimer();
// 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.
+14 -36
View File
@@ -2,15 +2,20 @@
{% 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">{{ 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 %} {% 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-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>
@@ -22,13 +27,6 @@
{% block scripts %} {% block scripts %}
<script> <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) { function colorizeLine(line) {
let cls = ""; let cls = "";
if (line.includes(" ist erreichbar!")) cls = "online"; if (line.includes(" ist erreichbar!")) cls = "online";
@@ -42,9 +40,8 @@ function colorizeLine(line) {
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ interval | int }}; const intervalMinutes = {{ global_check_interval | int }};
const intervalMilliseconds = intervalMinutes * 60 * 1000; const intervalMilliseconds = intervalMinutes * 60 * 1000;
let lastUpdateTime = Date.now();
function renderLog(data) { function renderLog(data) {
const box = document.getElementById("log-box"); const box = document.getElementById("log-box");
@@ -55,17 +52,6 @@ document.addEventListener("DOMContentLoaded", () => {
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;
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() { function fetchLog() {
@@ -75,17 +61,9 @@ document.addEventListener("DOMContentLoaded", () => {
.catch(err => console.error(err)); .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); document.getElementById("refresh-btn").addEventListener("click", fetchLog);
fetchLog(); fetchLog();
setInterval(fetchLog, intervalMilliseconds); if (intervalMilliseconds) setInterval(fetchLog, intervalMilliseconds);
setInterval(updateTimer, 1000);
}); });
</script> </script>
{% endblock %} {% endblock %}
+21 -11
View File
@@ -8,17 +8,22 @@
{% set can_delete = current_user.has_permission('switches.delete') %} {% set can_delete = current_user.has_permission('switches.delete') %}
{% block page_title %}Switche{% endblock %} {% block page_title %}Switche{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% 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 %} {% 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-wrap">
<div class="table-toolbar"> <div class="table-toolbar">
<div class="search-input"> <div class="search-input">
@@ -27,13 +32,18 @@
</div> </div>
</div> </div>
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<table class="data-table" id="switchesTable"> <table class="data-table" id="switchesTable" data-sortable>
<thead> <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> </thead>
<tbody> <tbody>
{% for s in switches %} {% 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="cell-name">{{ s['hostname'] }}</td>
<td class="mono">{{ s['ip'] }}</td> <td class="mono">{{ s['ip'] }}</td>
<td> <td>
+22 -12
View File
@@ -2,26 +2,36 @@
{% set active_page = "users" %} {% set active_page = "users" %}
{% block page_title %}Benutzer{% endblock %} {% block page_title %}Benutzer{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% 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 %} {% 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 class="table-wrap">
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<table class="data-table"> <table class="data-table" data-sortable>
<thead><tr><th>Username</th><th>Name</th><th>Gruppe</th><th style="width:1%;">Aktionen</th></tr></thead> <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> <tbody>
{% for u in users %} {% 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="cell-name">{{ u['username'] }}</td>
<td class="text-dim"> <td class="text-dim">{{ full_name or '—' }}</td>
{% if u['first_name'] or u['last_name'] %}{{ [u['first_name'], u['last_name']]|select|join(' ') }}{% else %}—{% endif %}
</td>
<td> <td>
{% if u['is_admin'] %} {% if u['is_admin'] %}
<span class="pill admin">Admin</span> <span class="pill admin">Admin</span>