Reihenfolge in der Aktionen-Spalte getauscht (Neustart -> Update, statt Update -> Neustart) und der Einzel-Update-Button auf btn-primary (bereits vorhandene Akzentfarbe, orange) umgestellt, damit er sich vom neutralen Neustart-Button abhebt. Der Bulk-Button unten heisst jetzt 'Bulk-Update' statt 'Update starten'. Live end-to-end verifiziert: Testsystem-Update-Restart hat eine neue MAC/IP bekommen (52:54:00:AD:B1:73 / 192.168.82.51, Geraete-Eintrag in POETEST entsprechend aktualisiert) -- der neue Einzel-Update-Button hat darueber ein komplettes Update ausgeloest und erfolgreich durchlaufen (alle 6 Schritte einzeln bestaetigt: apt update, initramfs MODULES=most, apt upgrade, apt full-upgrade, apt clean, apt autoremove).
210 lines
9.4 KiB
HTML
210 lines
9.4 KiB
HTML
{% extends "base.html" %}
|
|
{% set active_page = "maintenance" %}
|
|
{% block page_title %}Wartung{% endblock %}
|
|
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten</div>{% endblock %}
|
|
|
|
{% block content %}
|
|
|
|
<div class="section-head">
|
|
<div>
|
|
<h2 style="font-size:16px;">Wartung (SSH)</h2>
|
|
<div class="hint">
|
|
Bulk-Update (<code>apt update & upgrade -y</code>) und Neustart per SSH direkt auf dem Gerät —
|
|
unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten
|
|
SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if not devices %}
|
|
<p class="text-faint" style="font-size:12.5px;">
|
|
Noch keine Geräte für die Wartung konfiguriert. Lege unter
|
|
<a href="{{ url_for('devices') }}" style="color:var(--accent-strong); font-weight:600;">Clients</a>
|
|
SSH-Zugangsdaten der Kategorie „Linux-Client“ an einem Gerät an — es erscheint danach automatisch hier.
|
|
</p>
|
|
{% else %}
|
|
|
|
<form method="post" action="{{ url_for('maintenance_update') }}" id="updateForm">
|
|
<div class="table-wrap">
|
|
<div class="table-toolbar">
|
|
<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="maintenanceSearch" placeholder="Geräte durchsuchen…" oninput="filterTable('maintenanceSearch','maintenanceTable')">
|
|
</div>
|
|
</div>
|
|
<div style="overflow-x:auto;">
|
|
<table class="data-table" id="maintenanceTable" data-sortable>
|
|
<thead><tr>
|
|
{% if can_run %}<th style="width:1%;"><input type="checkbox" id="selectAll" title="Alle auswählen"></th>{% endif %}
|
|
<th data-sort-key="name">Name</th>
|
|
<th data-sort-key="ip">IP-Adresse</th>
|
|
<th data-sort-key="user">SSH-User</th>
|
|
<th data-sort-key="status">Status</th>
|
|
<th style="width:1%;">Aktionen</th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{% for d in devices %}
|
|
{% set job = jobs.get(d['mac']) %}
|
|
<tr data-mac="{{ d['mac'] }}" data-sort-name="{{ d['name']|lower }}" data-sort-ip="{{ d['ip'] }}" data-sort-user="{{ d['cred_username']|lower }}">
|
|
{% if can_run %}
|
|
<td><input type="checkbox" name="macs" value="{{ d['mac'] }}" class="maint-check" {{ 'disabled' if not d['is_active'] else '' }}></td>
|
|
{% endif %}
|
|
<td class="cell-name">{{ d['name'] }}{% if not d['is_active'] %} <span class="text-faint" style="font-size:11px;">(deaktiviert)</span>{% endif %}</td>
|
|
<td class="mono">{{ d['ip'] }}:{{ d['ssh_port'] or 22 }}</td>
|
|
<td class="mono">{{ d['cred_username'] }}</td>
|
|
<td class="maint-status">
|
|
{% if job %}
|
|
{% if job.status == 'running' %}<span class="pill unknown"><span class="spinner"></span> {{ job.message }}</span>
|
|
{% elif job.status == 'success' %}<span class="pill online">{{ job.message }}</span>
|
|
{% else %}<span class="pill offline">{{ job.message }}</span>
|
|
{% endif %}
|
|
{% else %}
|
|
<span class="pill disabled">Noch keine Aktion</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<div class="row-actions">
|
|
<button type="button" class="icon-btn" title="Ausgabe anzeigen/verbergen" onclick="toggleJobOutput('{{ d['mac'] }}')">
|
|
<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>
|
|
{% if can_run %}
|
|
<button type="submit" form="reboot-{{ d['mac'] }}" class="btn btn-sm btn-secondary">Neustart</button>
|
|
<button type="submit" form="update-{{ d['mac'] }}" class="btn btn-sm btn-primary">Update</button>
|
|
{% endif %}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr class="job-output-row" id="joboutput-{{ d['mac'] }}" style="display:none;">
|
|
<td colspan="{{ 6 if can_run else 5 }}">
|
|
<pre class="job-output-pre">{{ job.output if job and job.output else 'Keine Ausgabe.' }}</pre>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{% if can_run %}
|
|
<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) 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">
|
|
<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-Update
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</form>
|
|
|
|
{% if can_run %}
|
|
{% 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?">
|
|
<input type="hidden" name="macs" value="{{ d['mac'] }}">
|
|
</form>
|
|
{% endfor %}
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
document.querySelectorAll(".maint-check").forEach(function (cb) { cb.checked = false; });
|
|
const selectAllCb = document.getElementById("selectAll");
|
|
if (selectAllCb) selectAllCb.checked = false;
|
|
|
|
document.getElementById("selectAll")?.addEventListener("change", function () {
|
|
document.querySelectorAll(".maint-check").forEach(function (cb) {
|
|
if (!cb.disabled) cb.checked = this.checked;
|
|
}, 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) {
|
|
const q = document.getElementById(inputId).value.trim().toLowerCase();
|
|
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
|
|
if (row.classList.contains("empty-row") || row.classList.contains("job-output-row")) return;
|
|
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
|
});
|
|
}
|
|
|
|
function toggleJobOutput(mac) {
|
|
const row = document.getElementById("joboutput-" + mac);
|
|
if (!row) return;
|
|
const opening = row.style.display === "none" || !row.style.display;
|
|
row.style.display = opening ? "table-row" : "none";
|
|
if (opening) {
|
|
const pre = row.querySelector(".job-output-pre");
|
|
if (pre) pre.scrollTop = pre.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function isNearBottom(el) {
|
|
return el.scrollHeight - el.scrollTop - el.clientHeight < 20;
|
|
}
|
|
|
|
function statusPillHtml(job) {
|
|
if (!job) return '<span class="pill disabled">Noch keine Aktion</span>';
|
|
const msg = job.message || "";
|
|
if (job.status === "running") return '<span class="pill unknown"><span class="spinner"></span> ' + msg + '</span>';
|
|
if (job.status === "success") return '<span class="pill online">' + msg + '</span>';
|
|
return '<span class="pill offline">' + msg + '</span>';
|
|
}
|
|
|
|
let maintPollTimer = null;
|
|
function pollMaintenanceStatus() {
|
|
fetch("{{ url_for('maintenance_status') }}")
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (jobs) {
|
|
let anyRunning = false;
|
|
document.querySelectorAll("tr[data-mac]").forEach(function (row) {
|
|
const mac = row.dataset.mac;
|
|
const job = jobs[mac];
|
|
const cell = row.querySelector(".maint-status");
|
|
if (cell) cell.innerHTML = statusPillHtml(job);
|
|
const outRow = document.getElementById("joboutput-" + mac);
|
|
if (outRow && job) {
|
|
const pre = outRow.querySelector(".job-output-pre");
|
|
if (pre) {
|
|
const stick = isNearBottom(pre);
|
|
pre.textContent = job.output || "Keine Ausgabe.";
|
|
if (stick) pre.scrollTop = pre.scrollHeight;
|
|
}
|
|
}
|
|
if (job && job.status === "running") anyRunning = true;
|
|
});
|
|
clearTimeout(maintPollTimer);
|
|
maintPollTimer = setTimeout(pollMaintenanceStatus, anyRunning ? 2000 : 8000);
|
|
})
|
|
.catch(function () { maintPollTimer = setTimeout(pollMaintenanceStatus, 8000); });
|
|
}
|
|
pollMaintenanceStatus();
|
|
</script>
|
|
{% endblock %}
|