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()})
|
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):
|
def _maintenance_run_update(mac, name, host, port, username, password):
|
||||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
_maintenance_job_set(
|
_maintenance_job_set(
|
||||||
mac, action="update", status="running", message="Update läuft …",
|
mac, action="update", status="running", message="Update läuft …",
|
||||||
output="", started=now, finished=None,
|
output="", started=now, finished=None,
|
||||||
)
|
)
|
||||||
# timeout=600: ein echtes "apt upgrade" kann bei vielen ausstehenden
|
|
||||||
# Paketen mehrere Minuten dauern -- der kurze Standard-Timeout der
|
# Zeilen kommen live rein statt erst am Ende komplett -- Grundlage für
|
||||||
# interaktiven SSH-Aktionen (25s) wäre hier viel zu knapp.
|
# die automatisch mitscrollende Live-Ausgabe im Frontend. Jede Zeile
|
||||||
result = _run_ssh_command(
|
# bekommt ihren eigenen Zeitstempel (wann sie tatsächlich ankam, nicht
|
||||||
host, port, username, password, _apt_upgrade_command(),
|
# nur Start/Ende des gesamten Updates). Puffer bewusst auf die letzten
|
||||||
sudo_password=password, timeout=600,
|
# _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")
|
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"]:
|
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']}")
|
log_action_system("maintenance.update", name, f"fehlgeschlagen: {result['error']}")
|
||||||
elif result["success"]:
|
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")
|
log_action_system("maintenance.update", name, "erfolgreich")
|
||||||
else:
|
else:
|
||||||
|
lines.append(f"{finished} ✖ Update fehlgeschlagen (Exit-Code {result['exit_code']})")
|
||||||
_maintenance_job_set(
|
_maintenance_job_set(
|
||||||
mac, status="error",
|
mac, status="error",
|
||||||
message=f"Update fehlgeschlagen (Exit-Code {result['exit_code']}).",
|
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']}")
|
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
|
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):
|
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,
|
"""Führt EIN nicht-interaktives Kommando per SSH aus (kein Terminal,
|
||||||
keine Rückfragemöglichkeit). Gibt ein dict zurück:
|
keine Rückfragemöglichkeit). Gibt ein dict zurück:
|
||||||
|
|||||||
@@ -152,7 +152,22 @@ function filterTable(inputId, tableId) {
|
|||||||
|
|
||||||
function toggleJobOutput(mac) {
|
function toggleJobOutput(mac) {
|
||||||
const row = document.getElementById("joboutput-" + 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) {
|
function statusPillHtml(job) {
|
||||||
@@ -177,7 +192,11 @@ function pollMaintenanceStatus() {
|
|||||||
const outRow = document.getElementById("joboutput-" + mac);
|
const outRow = document.getElementById("joboutput-" + mac);
|
||||||
if (outRow && job) {
|
if (outRow && job) {
|
||||||
const pre = outRow.querySelector(".job-output-pre");
|
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;
|
if (job && job.status === "running") anyRunning = true;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user