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:
+139
-10
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user