Wartung: SSH-Bulk-Update & -Neustart für Linux-Clients
Neue Seite "Wartung" (Geräte-Bereich, eigenes Rechtepaar maintenance.view/maintenance.edit), komplett getrennt vom PoE-Neustart auf dem Dashboard: - Dashboard-Neustart bleibt ausschließlich PoE-Reset über den Switch - Wartung läuft immer per SSH direkt auf dem Gerät, unabhängig von jeder Switch-Zuordnung -- nur Geräte mit SSH-Zugangsdaten der Kategorie "Linux-Client" erscheinen dort (Windows/PowerShell folgt als eigener, separater Schritt -- kein Testsystem dafür verfügbar) Backend: - _run_ssh_command()/_run_ssh_reboot(): nicht-interaktive SSH-Ausführung mit paramiko.RejectPolicy() gegen dieselbe known_hosts-Datei wie das interaktive Browser-Terminal -- ein Host muss vorher einmal per "Verbindung testen" bestätigt worden sein, da hier (anders als im Terminal) niemand eine "yes/no"-Rückfrage beantworten kann - _apt_upgrade_command(): "apt update && apt upgrade -y" nicht- interaktiv mit DEBIAN_FRONTEND=noninteractive und --force-confdef/--force-confold, damit ein Paket-Postinst-Skript nicht auf eine nie kommende Config-Rückfrage wartet - Reboot behandelt einen abrupten Verbindungsabbruch NACH dem Kommando als Erfolg (die Maschine fährt herunter, bevor sie antworten kann); nur ein Fehler VOR dem Reboot-Kommando zählt als echter Fehler - Job-Status lebt im Prozessspeicher (wie das SSH-Terminal), Live- Anzeige per Polling (/maintenance/status) Live gegen ein dediziertes Testsystem (getrennt vom gemeinsam genutzten App-Host, um dort kein echtes apt upgrade auszulösen) verifiziert: echtes "apt update && apt upgrade" inkl. Kernel-/systemd-/ netplan-Paketen lief vollständig durch, anschließender SSH-Neustart hat das System nachweislich neu gestartet (uptime nach dem Job bestätigt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
|
||||
"transfer": '<path d="M17 3l4 4-4 4"/><path d="M3 7h18"/><path d="M7 21l-4-4 4-4"/><path d="M21 17H3"/>',
|
||||
"network": '<rect x="9" y="2" width="6" height="6" rx="1.2"/><rect x="2" y="16" width="6" height="6" rx="1.2"/><rect x="16" y="16" width="6" height="6" rx="1.2"/><path d="M12 8v4M12 12H5v4M12 12h7v4"/>',
|
||||
"wrench": '<path d="M14.7 6.3a4 4 0 11-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 015.4-5.4z"/>',
|
||||
} %}
|
||||
|
||||
<div class="app-shell">
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
{% 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 style="overflow-x:auto;">
|
||||
<table class="data-table" 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['rpi_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['rpi_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 %}
|
||||
<form method="post" action="{{ url_for('maintenance_reboot') }}" data-confirm="{{ d['name'] }} ({{ d['rpi_ip'] }}) jetzt per SSH neu starten?">
|
||||
<input type="hidden" name="macs" value="{{ d['mac'] }}">
|
||||
<button type="submit" class="btn btn-sm btn-secondary">Neustart</button>
|
||||
</form>
|
||||
{% 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).</div>
|
||||
<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>
|
||||
Update starten
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById("selectAll")?.addEventListener("change", function () {
|
||||
document.querySelectorAll(".maint-check").forEach(function (cb) {
|
||||
if (!cb.disabled) cb.checked = this.checked;
|
||||
}, this);
|
||||
});
|
||||
|
||||
function toggleJobOutput(mac) {
|
||||
const row = document.getElementById("joboutput-" + mac);
|
||||
if (row) row.style.display = (row.style.display === "none" || !row.style.display) ? "table-row" : "none";
|
||||
}
|
||||
|
||||
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) pre.textContent = job.output || "Keine Ausgabe.";
|
||||
}
|
||||
if (job && job.status === "running") anyRunning = true;
|
||||
});
|
||||
clearTimeout(maintPollTimer);
|
||||
maintPollTimer = setTimeout(pollMaintenanceStatus, anyRunning ? 2000 : 8000);
|
||||
})
|
||||
.catch(function () { maintPollTimer = setTimeout(pollMaintenanceStatus, 8000); });
|
||||
}
|
||||
pollMaintenanceStatus();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user