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
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
Zugangsdaten können mehreren Switchen gleichzeitig zugeordnet werden. Beim Anlegen eines
|
<div>
|
||||||
Switches lassen sich bestehende Zugangsdaten auswählen oder direkt neue hinterlegen.
|
<h2 style="font-size:16px;">Zugangsdaten</h2>
|
||||||
</p>
|
<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 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
Über Gruppen lassen sich einzelne Verwaltungsrechte für Devices und Switches gezielt vergeben.
|
<div>
|
||||||
Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich. Auf „Rechte“ klicken,
|
<h2 style="font-size:16px;">Gruppen</h2>
|
||||||
um eine Gruppe aufzuklappen und die Berechtigungen im Detail zu sehen bzw. zu ändern.
|
<div class="hint">
|
||||||
</p>
|
Ü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 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>
|
||||||
<!-- Virtuelle "Admin"-Gruppe: Rechte sind fix (alles), Mitgliedschaft
|
<th data-sort-key="members">Mitglieder</th>
|
||||||
wird direkt über is_admin gesteuert. -->
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user