Wartung: Update-Ausgabe live streamen, Zeitstempel pro Zeile, Auto-Scroll

- Neue _run_ssh_command_streaming(): liest stdout/stderr zeilenweise WÄHREND
  das SSH-Update noch läuft, statt alles erst nach Abschluss auf einmal zu
  liefern. Jede Zeile bekommt sofort einen eigenen Zeitstempel und wird per
  _maintenance_job_set() live in den Job-Status geschrieben (bereits alle
  2s gepollt) -- Puffer auf die letzten 400 Zeilen begrenzt statt eines
  harten Zeichen-Limits.
- Eindeutiger Abschluss-Marker als letzte Ausgabezeile: "✔ All Updates
  Done" bei Erfolg, "✖ Update abgebrochen/fehlgeschlagen …" bei Fehler --
  vorher endete die Ausgabe einfach mitten im rohen apt-Output.
- Frontend: Ausgabefenster scrollt jetzt automatisch mit, sobald neue
  Zeilen reinkommen ("stick to bottom", solange man nicht selbst
  hochgescrollt hat) und beim ersten Öffnen direkt ans Ende.
- Live auf POETEST verifiziert: Zeilen tragen korrekte Zeitstempel, Update
  endet sichtbar mit "✔ All Updates Done".
This commit is contained in:
2026-08-13 16:02:07 +02:00
parent 51d105399b
commit 4c13127679
2 changed files with 160 additions and 12 deletions
+139 -10
View File
@@ -5399,32 +5399,57 @@ def maintenance_status():
return jsonify({mac: dict(job) for mac, job in _maintenance_jobs.items()})
_MAINTENANCE_OUTPUT_MAX_LINES = 400
def _maintenance_run_update(mac, name, host, port, username, password):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
_maintenance_job_set(
mac, action="update", status="running", message="Update läuft …",
output="", started=now, finished=None,
)
# timeout=600: ein echtes "apt upgrade" kann bei vielen ausstehenden
# Paketen mehrere Minuten dauern -- der kurze Standard-Timeout der
# interaktiven SSH-Aktionen (25s) wäre hier viel zu knapp.
result = _run_ssh_command(
host, port, username, password, _apt_upgrade_command(),
sudo_password=password, timeout=600,
# Zeilen kommen live rein statt erst am Ende komplett -- Grundlage für
# die automatisch mitscrollende Live-Ausgabe im Frontend. Jede Zeile
# bekommt ihren eigenen Zeitstempel (wann sie tatsächlich ankam, nicht
# nur Start/Ende des gesamten Updates). Puffer bewusst auf die letzten
# _MAINTENANCE_OUTPUT_MAX_LINES begrenzt (ganze Zeilen, kein hartes
# Zeichen-Limit mehr), damit ein sehr langes Update den
# Prozessspeicher nicht unbegrenzt wachsen lässt.
lines = []
def on_line(line, is_err):
lines.append(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {line}")
if len(lines) > _MAINTENANCE_OUTPUT_MAX_LINES:
del lines[: len(lines) - _MAINTENANCE_OUTPUT_MAX_LINES]
_maintenance_job_set(mac, output="\n".join(lines))
# overall_timeout=600: ein echtes "apt upgrade" kann bei vielen
# ausstehenden Paketen mehrere Minuten dauern -- der kurze
# Standard-Timeout der interaktiven SSH-Aktionen (25s) wäre hier viel
# zu knapp; der bleibt für den reinen Verbindungsaufbau bestehen.
result = _run_ssh_command_streaming(
host, port, username, password, _apt_upgrade_command(), on_line,
sudo_password=password, overall_timeout=600,
)
finished = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
combined_output = (result.get("stdout", "") + result.get("stderr", ""))[-6000:]
# Eindeutiger Abschluss-Marker als letzte Zeile -- vorher endete die
# Ausgabe einfach mitten im rohen apt-Output, ohne erkennbar zu machen,
# ob das Update tatsächlich fertig oder nur abgeschnitten war.
if result["error"]:
_maintenance_job_set(mac, status="error", message=result["error"], output=combined_output, finished=finished)
lines.append(f"{finished} ✖ Update abgebrochen: {result['error']}")
_maintenance_job_set(mac, status="error", message=result["error"], output="\n".join(lines), finished=finished)
log_action_system("maintenance.update", name, f"fehlgeschlagen: {result['error']}")
elif result["success"]:
_maintenance_job_set(mac, status="success", message="Update erfolgreich abgeschlossen.", output=combined_output, finished=finished)
lines.append(f"{finished} ✔ All Updates Done")
_maintenance_job_set(mac, status="success", message="Update erfolgreich abgeschlossen.", output="\n".join(lines), finished=finished)
log_action_system("maintenance.update", name, "erfolgreich")
else:
lines.append(f"{finished} ✖ Update fehlgeschlagen (Exit-Code {result['exit_code']})")
_maintenance_job_set(
mac, status="error",
message=f"Update fehlgeschlagen (Exit-Code {result['exit_code']}).",
output=combined_output, finished=finished,
output="\n".join(lines), finished=finished,
)
log_action_system("maintenance.update", name, f"fehlgeschlagen: Exit-Code {result['exit_code']}")
@@ -5900,6 +5925,110 @@ def _resolve_device_ssh_target(conn, device):
return device["ip"], port, cred["username"], password, None
def _run_ssh_command_streaming(host, port, username, password, command, on_line,
sudo_password=None, connect_timeout=25, overall_timeout=600):
"""Wie _run_ssh_command, liest die Ausgabe aber zeilenweise WÄHREND das
Kommando noch läuft, statt sie erst nach Abschluss komplett auf einmal
zu liefern -- Grundlage für die live mitwachsende, automatisch
mitscrollende Ausgabe beim Wartungs-Update (siehe
pollMaintenanceStatus() in maintenance.html). on_line(line, is_stderr)
wird für jede vollständige Zeile aufgerufen, sobald sie eintrifft."""
result = {"success": False, "exit_code": None, "stdout": "", "stderr": "", "error": None}
if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
result["error"] = (
"Host-Key nicht bekannt — bitte zuerst per 'Verbindung testen' "
"einmal interaktiv verbinden und den Host-Key bestätigen."
)
return result
client = paramiko.SSHClient()
client.load_host_keys(SSH_KNOWN_HOSTS_PATH)
client.set_missing_host_key_policy(paramiko.RejectPolicy())
try:
client.connect(
host, port=port, username=username, password=password,
timeout=connect_timeout, banner_timeout=connect_timeout, auth_timeout=connect_timeout,
look_for_keys=False, allow_agent=False,
)
except paramiko.BadHostKeyException:
result["error"] = "Host-Key hat sich geändert — Verbindung abgelehnt (möglicher Man-in-the-Middle)."
return result
except paramiko.AuthenticationException:
result["error"] = "Authentifizierung fehlgeschlagen (falsche Zugangsdaten)."
return result
except paramiko.SSHException as e:
result["error"] = f"Host-Key unbekannt — bitte zuerst per 'Verbindung testen' bestätigen ({e})."
return result
except (OSError, socket.error) as e:
result["error"] = f"Verbindung fehlgeschlagen: {e}"
return result
out_parts, err_parts = [], []
out_buf, err_buf = bytearray(), bytearray()
def drain(ready, recv, buf, parts, is_err):
got_data = False
while ready():
chunk = recv(4096)
if not chunk:
break
buf.extend(chunk)
got_data = True
while b"\n" in buf:
idx = buf.index(b"\n")
line = bytes(buf[:idx]).decode("utf-8", errors="replace").rstrip("\r")
del buf[:idx + 1]
parts.append(line)
on_line(line, is_err)
return got_data
try:
stdin, stdout, stderr = client.exec_command(command, timeout=connect_timeout)
channel = stdout.channel
if sudo_password is not None:
try:
stdin.write(sudo_password + "\n")
stdin.flush()
except OSError:
pass
deadline = time.time() + overall_timeout
while True:
got = drain(channel.recv_ready, channel.recv, out_buf, out_parts, False)
got = drain(channel.recv_stderr_ready, channel.recv_stderr, err_buf, err_parts, True) or got
if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready():
break
if time.time() > deadline:
result["error"] = f"Kommando nach {overall_timeout}s abgebrochen (Timeout)."
return result
if not got:
time.sleep(0.2)
# Ein letzter, nicht mit "\n" abgeschlossener Rest zählt noch als Zeile
# (apt beendet die letzte Ausgabezeile nicht immer mit Zeilenumbruch).
if out_buf:
line = bytes(out_buf).decode("utf-8", errors="replace")
out_parts.append(line)
on_line(line, False)
if err_buf:
line = bytes(err_buf).decode("utf-8", errors="replace")
err_parts.append(line)
on_line(line, True)
result["exit_code"] = channel.recv_exit_status()
result["stdout"] = "\n".join(out_parts)
result["stderr"] = "\n".join(err_parts)
result["success"] = result["exit_code"] == 0
except (paramiko.SSHException, OSError) as e:
result["error"] = f"Kommando fehlgeschlagen: {e}"
finally:
try:
client.close()
except Exception:
pass
return result
def _run_ssh_command(host, port, username, password, command, sudo_password=None, timeout=25):
"""Führt EIN nicht-interaktives Kommando per SSH aus (kein Terminal,
keine Rückfragemöglichkeit). Gibt ein dict zurück:
+21 -2
View File
@@ -152,7 +152,22 @@ function filterTable(inputId, tableId) {
function toggleJobOutput(mac) {
const row = document.getElementById("joboutput-" + mac);
if (row) row.style.display = (row.style.display === "none" || !row.style.display) ? "table-row" : "none";
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;
}
}
// "Klebt" am unteren Rand, solange man nicht selbst nach oben gescrollt hat
// -- reine Update-Ausgabe kann bei vielen Paketen sehr lang werden, ohne
// das würde man sie sonst manuell nachziehen müssen, um live mitzulesen.
// Ein kleiner Schwellwert (20px) toleriert, dass "ganz unten" durch
// Rundungsfehler beim Scrollen selten exakt 0 ist.
function isNearBottom(el) {
return el.scrollHeight - el.scrollTop - el.clientHeight < 20;
}
function statusPillHtml(job) {
@@ -177,7 +192,11 @@ function pollMaintenanceStatus() {
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 (pre) {
const stick = isNearBottom(pre);
pre.textContent = job.output || "Keine Ausgabe.";
if (stick) pre.scrollTop = pre.scrollHeight;
}
}
if (job && job.status === "running") anyRunning = true;
});