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:
@@ -29,8 +29,14 @@ Die App ermöglicht:
|
||||
Geräten bietet das Detail-Popup stattdessen direkt eine Aktivieren-Option
|
||||
- Einstellbares Prüfintervall (steuert den Hintergrund-Check-Dienst) —
|
||||
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)
|
||||
- **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“
|
||||
überall mit aufgeführt); normalen Benutzern lassen sich über Gruppen
|
||||
einzelne Verwaltungsrechte gezielt zuweisen, inkl. reiner Ansichtsrechte
|
||||
|
||||
@@ -651,6 +651,40 @@ def _format_ts(ts_str: str) -> 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):
|
||||
"""
|
||||
Ermittelt aus dem aktuellsten Logfile in einem Durchlauf je Gerät:
|
||||
|
||||
@@ -671,6 +671,22 @@ table.data-table {
|
||||
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 {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -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 ---------------- */
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
@@ -224,6 +299,8 @@
|
||||
initConfirmables();
|
||||
initFlashedMessages();
|
||||
initNavGroups();
|
||||
initCheckTimer();
|
||||
initSortableTables();
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<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 %}
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -8,16 +8,21 @@
|
||||
{% 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 %}
|
||||
|
||||
{% 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 %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<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>
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
<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. -->
|
||||
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 %}
|
||||
<!-- 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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
{% 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>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
{% block content %}
|
||||
<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 %}
|
||||
|
||||
@@ -8,16 +8,21 @@
|
||||
{% 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 %}
|
||||
|
||||
{% 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 %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
{% 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>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user