From 4c1312767977f8675d316daaff2246f151810c93 Mon Sep 17 00:00:00 2001 From: alientim Date: Thu, 13 Aug 2026 16:02:07 +0200 Subject: [PATCH] Wartung: Update-Ausgabe live streamen, Zeitstempel pro Zeile, Auto-Scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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". --- srv/tesm/app.py | 149 ++++++++++++++++++++++++++-- srv/tesm/templates/maintenance.html | 23 ++++- 2 files changed, 160 insertions(+), 12 deletions(-) diff --git a/srv/tesm/app.py b/srv/tesm/app.py index 7362ef6..08fa72f 100644 --- a/srv/tesm/app.py +++ b/srv/tesm/app.py @@ -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: diff --git a/srv/tesm/templates/maintenance.html b/srv/tesm/templates/maintenance.html index cb19af5..b207bd8 100644 --- a/srv/tesm/templates/maintenance.html +++ b/srv/tesm/templates/maintenance.html @@ -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; });