Papierkorb: Sortieren/Suchen; Wartung: Einzel-Update-Button + Bulk-Neustart

Papierkorb: jede der fuenf Kategorien (Clients/Switche/Zugangsdaten/
Benutzer/Gruppen) bekommt jetzt dasselbe Sortieren/Suchen wie die
uebrigen Listenseiten (data-sortable + Such-Eingabe je Tabelle, nur
wenn die Kategorie tatsaechlich Eintraege hat).

Wartung: Update liess sich bisher nur per Bulk (Checkbox + 'Update
starten') ausloesen. Jedes Geraet bekommt jetzt zusaetzlich einen
eigenen 'Update'-Button (analog zum bereits vorhandenen 'Neustart'-
Button, ueber ein eigenstaendiges <form> + form="update-<mac>"-
Referenz -- kein verschachteltes <form>). Zusaetzlich ein 'Bulk-
Neustart'-Button neben 'Update starten', der dieselben Checkboxen/
dasselbe #updateForm nutzt wie das Bulk-Update (Ziel-Route wird per JS
nur fuer diesen einen Submit umgebogen), inkl. Bestaetigungsdialog wie
beim Einzel-Neustart.

Serverseitig war dafuer keine Aenderung noetig -- _maintenance_dispatch()
verarbeitete beliebig viele MACs schon immer generisch, die bisherigen
Formulare haben nur zufaellig immer genau eine bzw. alle uebermittelt.

Live auf POETEST verifiziert: Papierkorb zeigt Sortieren/Suchen nur bei
tatsaechlich befuellten Kategorien; der neue Einzel-Update-Button loest
korrekt einen Job fuer genau dieses eine Geraet aus (SSH-Verbindungs-
versuch zum richtigen Ziel bestaetigt, sauber behandelter Fehler, da das
Testsystem gerade offline war).
This commit is contained in:
2026-08-22 09:53:28 +02:00
parent d90095885f
commit 7b17c952ba
2 changed files with 122 additions and 25 deletions
+29 -1
View File
@@ -68,6 +68,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button> </button>
{% if can_run %} {% if can_run %}
<button type="submit" form="update-{{ d['mac'] }}" class="btn btn-sm btn-secondary">Update</button>
<button type="submit" form="reboot-{{ d['mac'] }}" class="btn btn-sm btn-secondary">Neustart</button> <button type="submit" form="reboot-{{ d['mac'] }}" class="btn btn-sm btn-secondary">Neustart</button>
{% endif %} {% endif %}
</div> </div>
@@ -86,17 +87,26 @@
{% if can_run %} {% if can_run %}
<div class="section-head" style="margin-top:16px;"> <div class="section-head" style="margin-top:16px;">
<div class="hint">Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet).</div> <div class="hint">Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet) oder neu starten.</div>
<div style="display:flex; gap:8px;">
<button type="button" class="btn btn-secondary" onclick="bulkReboot()">
<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>
Bulk-Neustart
</button>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg> <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>
Update starten Update starten
</button> </button>
</div>
</div> </div>
{% endif %} {% endif %}
</form> </form>
{% if can_run %} {% if can_run %}
{% for d in devices %} {% for d in devices %}
<form method="post" action="{{ url_for('maintenance_update') }}" id="update-{{ d['mac'] }}">
<input type="hidden" name="macs" value="{{ d['mac'] }}">
</form>
<form method="post" action="{{ url_for('maintenance_reboot') }}" id="reboot-{{ d['mac'] }}" data-confirm="{{ d['name'] }} ({{ d['ip'] }}) jetzt per SSH neu starten?"> <form method="post" action="{{ url_for('maintenance_reboot') }}" id="reboot-{{ d['mac'] }}" data-confirm="{{ d['name'] }} ({{ d['ip'] }}) jetzt per SSH neu starten?">
<input type="hidden" name="macs" value="{{ d['mac'] }}"> <input type="hidden" name="macs" value="{{ d['mac'] }}">
</form> </form>
@@ -118,6 +128,24 @@ document.getElementById("selectAll")?.addEventListener("change", function () {
}, this); }, this);
}); });
// Nutzt bewusst dieselben Checkboxen/dasselbe #updateForm wie "Update
// starten" (kein eigenes, verschachteltes <form> noetig) -- die
// action wird nur fuer diesen einen Submit umgebogen, ein Seitenaufruf
// danach setzt sie ueber den Server-seitig gerenderten Ausgangszustand
// ohnehin wieder zurueck.
function bulkReboot() {
const checked = document.querySelectorAll(".maint-check:checked");
if (!checked.length) {
showToast("Bitte mindestens ein Gerät auswählen.", "danger");
return;
}
window.confirmAction(`${checked.length} ausgewählte Gerät(e) jetzt per SSH neu starten?`, function () {
const form = document.getElementById("updateForm");
form.action = "{{ url_for('maintenance_reboot') }}";
form.submit();
});
}
function filterTable(inputId, tableId) { function filterTable(inputId, tableId) {
const q = document.getElementById(inputId).value.trim().toLowerCase(); const q = document.getElementById(inputId).value.trim().toLowerCase();
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => { document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
+89 -20
View File
@@ -22,12 +22,24 @@
<div class="card card-pad"> <div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Clients ({{ trash.devices|length }})</h3> <h3 style="font-size:14px; margin:0 0 12px;">Clients ({{ trash.devices|length }})</h3>
{% if trash.devices %} {% if trash.devices %}
<div class="table-wrap"><div style="overflow-x:auto;"> <div class="table-wrap">
<table class="data-table"> <div class="table-toolbar">
<thead><tr><th>Name</th><th>IP</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead> <div class="search-input">
<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="devicesTrashSearch" placeholder="Clients durchsuchen…" oninput="filterTable('devicesTrashSearch','devicesTrashTable')">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="devicesTrashTable" data-sortable>
<thead><tr>
<th data-sort-key="name">Name</th>
<th data-sort-key="ip">IP</th>
<th data-sort-key="deleted">Gelöscht am</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<tbody> <tbody>
{% for d in trash.devices %} {% for d in trash.devices %}
<tr> <tr data-sort-name="{{ d['name']|lower }}" data-sort-ip="{{ d['ip']|lower }}" data-sort-deleted="{{ d['deleted_at']|lower }}">
<td class="cell-name">{{ d['name'] }}</td> <td class="cell-name">{{ d['name'] }}</td>
<td class="mono">{{ d['ip'] }}</td> <td class="mono">{{ d['ip'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ d['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ d['deleted_at'] }}</td>
@@ -58,12 +70,24 @@
<div class="card card-pad"> <div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Switche ({{ trash.switches|length }})</h3> <h3 style="font-size:14px; margin:0 0 12px;">Switche ({{ trash.switches|length }})</h3>
{% if trash.switches %} {% if trash.switches %}
<div class="table-wrap"><div style="overflow-x:auto;"> <div class="table-wrap">
<table class="data-table"> <div class="table-toolbar">
<thead><tr><th>Hostname</th><th>IP</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead> <div class="search-input">
<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="switchesTrashSearch" placeholder="Switche durchsuchen…" oninput="filterTable('switchesTrashSearch','switchesTrashTable')">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="switchesTrashTable" data-sortable>
<thead><tr>
<th data-sort-key="hostname">Hostname</th>
<th data-sort-key="ip">IP</th>
<th data-sort-key="deleted">Gelöscht am</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<tbody> <tbody>
{% for s in trash.switches %} {% for s in trash.switches %}
<tr> <tr data-sort-hostname="{{ s['hostname']|lower }}" data-sort-ip="{{ s['ip']|lower }}" data-sort-deleted="{{ s['deleted_at']|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 class="text-faint" style="font-size:12px;">{{ s['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ s['deleted_at'] }}</td>
@@ -94,12 +118,24 @@
<div class="card card-pad"> <div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Zugangsdaten ({{ trash.credentials|length }})</h3> <h3 style="font-size:14px; margin:0 0 12px;">Zugangsdaten ({{ trash.credentials|length }})</h3>
{% if trash.credentials %} {% if trash.credentials %}
<div class="table-wrap"><div style="overflow-x:auto;"> <div class="table-wrap">
<table class="data-table"> <div class="table-toolbar">
<thead><tr><th>Name</th><th>Username</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead> <div class="search-input">
<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="credentialsTrashSearch" placeholder="Zugangsdaten durchsuchen…" oninput="filterTable('credentialsTrashSearch','credentialsTrashTable')">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="credentialsTrashTable" data-sortable>
<thead><tr>
<th data-sort-key="name">Name</th>
<th data-sort-key="username">Username</th>
<th data-sort-key="deleted">Gelöscht am</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<tbody> <tbody>
{% for c in trash.credentials %} {% for c in trash.credentials %}
<tr> <tr data-sort-name="{{ c['name']|lower }}" data-sort-username="{{ c['username']|lower }}" data-sort-deleted="{{ c['deleted_at']|lower }}">
<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-faint" style="font-size:12px;">{{ c['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ c['deleted_at'] }}</td>
@@ -130,13 +166,25 @@
<div class="card card-pad"> <div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Benutzer ({{ trash.users|length }})</h3> <h3 style="font-size:14px; margin:0 0 12px;">Benutzer ({{ trash.users|length }})</h3>
{% if trash.users %} {% if trash.users %}
<div class="table-wrap"><div style="overflow-x:auto;"> <div class="table-wrap">
<table class="data-table"> <div class="table-toolbar">
<thead><tr><th>Username</th><th>Name</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead> <div class="search-input">
<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="usersTrashSearch" placeholder="Benutzer durchsuchen…" oninput="filterTable('usersTrashSearch','usersTrashTable')">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="usersTrashTable" data-sortable>
<thead><tr>
<th data-sort-key="username">Username</th>
<th data-sort-key="name">Name</th>
<th data-sort-key="deleted">Gelöscht am</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<tbody> <tbody>
{% for u in trash.users %} {% for u in trash.users %}
{% set full_name = [u['first_name'], u['last_name']]|select|join(' ') %} {% set full_name = [u['first_name'], u['last_name']]|select|join(' ') %}
<tr> <tr data-sort-username="{{ u['username']|lower }}" data-sort-name="{{ full_name|lower }}" data-sort-deleted="{{ u['deleted_at']|lower }}">
<td class="cell-name">{{ u['username'] }}</td> <td class="cell-name">{{ u['username'] }}</td>
<td class="text-dim">{{ full_name or '—' }}</td> <td class="text-dim">{{ full_name or '—' }}</td>
<td class="text-faint" style="font-size:12px;">{{ u['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ u['deleted_at'] }}</td>
@@ -167,12 +215,23 @@
<div class="card card-pad"> <div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Gruppen ({{ trash.groups|length }})</h3> <h3 style="font-size:14px; margin:0 0 12px;">Gruppen ({{ trash.groups|length }})</h3>
{% if trash.groups %} {% if trash.groups %}
<div class="table-wrap"><div style="overflow-x:auto;"> <div class="table-wrap">
<table class="data-table"> <div class="table-toolbar">
<thead><tr><th>Name</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead> <div class="search-input">
<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="groupsTrashSearch" placeholder="Gruppen durchsuchen…" oninput="filterTable('groupsTrashSearch','groupsTrashTable')">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="groupsTrashTable" data-sortable>
<thead><tr>
<th data-sort-key="name">Name</th>
<th data-sort-key="deleted">Gelöscht am</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<tbody> <tbody>
{% for g in trash.groups %} {% for g in trash.groups %}
<tr> <tr data-sort-name="{{ g['name']|lower }}" data-sort-deleted="{{ g['deleted_at']|lower }}">
<td class="cell-name">{{ g['name'] }}</td> <td class="cell-name">{{ g['name'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ g['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ g['deleted_at'] }}</td>
<td> <td>
@@ -201,4 +260,14 @@
</div> </div>
<script>
function filterTable(inputId, tableId) {
const q = document.getElementById(inputId).value.trim().toLowerCase();
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
if (row.classList.contains("empty-row")) return;
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
</script>
{% endblock %} {% endblock %}