feat: Live-Download-Fortschritt + Jobs abbrechen
Job-Engine:
- Ausgabe wird binaer + byteweise gelesen und CRLF-bewusst verarbeitet:
einzelnes \r (tqdm/hf-Fortschritt) ueberschreibt die letzte Logzeile statt
sie zu fluten; \r\n bzw. \n = echte neue Zeile. So kommt der Download-
Fortschritt live an. (Text-Modus wuerde \r zu \n uebersetzen -> daher binaer.)
- cancel_job(): laufenden Prozess terminieren, Status "canceled", _PROCS-Registry
- POST /api/jobs/{id}/cancel
Frontend:
- Aktivitaet: Fortschrittsbalken + %-Anzeige aus der letzten Logzeile,
"Abbrechen"-Button je laufendem Job, Status "abgebrochen"
- Server/Aktueller Vorgang: Fortschrittsbalken + Abbrechen (mit Rueckfrage),
"abgebrochen"-Behandlung
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+80
-8
@@ -14,9 +14,56 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
JOBS: dict[str, dict] = {}
|
JOBS: dict[str, dict] = {}
|
||||||
|
_PROCS: dict[str, subprocess.Popen] = {} # laufende Prozesse je Job-ID (fuer Abbruch)
|
||||||
_LOG_CAP = 400
|
_LOG_CAP = 400
|
||||||
|
|
||||||
|
|
||||||
|
def _append_log(job: dict, line: str) -> None:
|
||||||
|
job["log"].append(line)
|
||||||
|
if len(job["log"]) > _LOG_CAP:
|
||||||
|
del job["log"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def _pump_output(job: dict, stream) -> None:
|
||||||
|
"""Liest die Ausgabe byteweise und behandelt `\\r` (Fortschrittsbalken wie bei
|
||||||
|
`hf download`/tqdm) als Ueberschreiben der letzten Zeile statt als neue Zeile.
|
||||||
|
So erscheint der Download-Fortschritt live, ohne das Log mit tausenden Frames zu
|
||||||
|
fluten. Bewusst BINAER gelesen: im Text-Modus wuerde Python ein einzelnes `\\r`
|
||||||
|
zu `\\n` uebersetzen (universal newlines) und das Ueberschreiben unmoeglich machen."""
|
||||||
|
buf = b""
|
||||||
|
overwrite = False # soll die naechste committete Zeile die letzte ueberschreiben?
|
||||||
|
pending_cr = False # haben wir gerade ein `\r` gesehen und warten auf das naechste Byte?
|
||||||
|
|
||||||
|
def commit():
|
||||||
|
line = buf.decode("utf-8", "replace")
|
||||||
|
if overwrite and job["log"]:
|
||||||
|
job["log"][-1] = line
|
||||||
|
else:
|
||||||
|
_append_log(job, line)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
ch = stream.read(1)
|
||||||
|
if not ch:
|
||||||
|
break
|
||||||
|
if pending_cr:
|
||||||
|
pending_cr = False
|
||||||
|
if ch == b"\n": # `\r\n` zusammen = echte neue Zeile
|
||||||
|
commit(); overwrite = False; buf = b""
|
||||||
|
continue
|
||||||
|
commit(); overwrite = True; buf = b"" # einzelnes `\r` = Fortschritt (ueberschreiben)
|
||||||
|
# ch faellt unten normal durch
|
||||||
|
if ch == b"\r":
|
||||||
|
pending_cr = True
|
||||||
|
elif ch == b"\n":
|
||||||
|
commit(); overwrite = False; buf = b""
|
||||||
|
else:
|
||||||
|
buf += ch
|
||||||
|
if pending_cr:
|
||||||
|
commit(); overwrite = True; buf = b""
|
||||||
|
if buf:
|
||||||
|
commit()
|
||||||
|
|
||||||
|
|
||||||
def _run_job(job_id: str, args: list[str], env: dict | None = None, stdin_data: str | None = None):
|
def _run_job(job_id: str, args: list[str], env: dict | None = None, stdin_data: str | None = None):
|
||||||
job = JOBS[job_id]
|
job = JOBS[job_id]
|
||||||
job["state"] = "running"
|
job["state"] = "running"
|
||||||
@@ -26,31 +73,56 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, stdin_data:
|
|||||||
stdin=subprocess.PIPE if stdin_data is not None else None,
|
stdin=subprocess.PIPE if stdin_data is not None else None,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
bufsize=0, # binaer + ungepuffert -> Fortschritt (\r) kommt live an
|
||||||
bufsize=1,
|
|
||||||
env={**os.environ, **(env or {})},
|
env={**os.environ, **(env or {})},
|
||||||
)
|
)
|
||||||
|
_PROCS[job_id] = proc
|
||||||
if stdin_data is not None:
|
if stdin_data is not None:
|
||||||
# Secret (z.B. sudo-Passwort) ueber stdin fuettern — landet NICHT im Log/ps.
|
# Secret (z.B. sudo-Passwort) ueber stdin fuettern — landet NICHT im Log/ps.
|
||||||
try:
|
try:
|
||||||
proc.stdin.write(stdin_data) # type: ignore[union-attr]
|
proc.stdin.write(stdin_data.encode()) # type: ignore[union-attr]
|
||||||
proc.stdin.close() # type: ignore[union-attr]
|
proc.stdin.close() # type: ignore[union-attr]
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
for line in proc.stdout: # type: ignore[union-attr]
|
_pump_output(job, proc.stdout)
|
||||||
job["log"].append(line.rstrip("\n"))
|
|
||||||
if len(job["log"]) > _LOG_CAP:
|
|
||||||
del job["log"][0]
|
|
||||||
proc.wait()
|
proc.wait()
|
||||||
|
if job.get("canceled"):
|
||||||
|
job["state"] = "canceled"
|
||||||
|
job["returncode"] = proc.returncode
|
||||||
|
_append_log(job, "[mission-control] Vorgang abgebrochen.")
|
||||||
|
else:
|
||||||
job["returncode"] = proc.returncode
|
job["returncode"] = proc.returncode
|
||||||
job["state"] = "done" if proc.returncode == 0 else "failed"
|
job["state"] = "done" if proc.returncode == 0 else "failed"
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
job["log"].append(f"[mission-control] Fehler: {exc}")
|
_append_log(job, f"[mission-control] Fehler: {exc}")
|
||||||
job["state"] = "failed"
|
job["state"] = "failed"
|
||||||
job["returncode"] = -1
|
job["returncode"] = -1
|
||||||
|
finally:
|
||||||
|
_PROCS.pop(job_id, None)
|
||||||
job["finished_at"] = time.time()
|
job["finished_at"] = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_job(job_id: str) -> bool:
|
||||||
|
"""Laufenden Job abbrechen: Prozess terminieren. Liefert False, wenn der Job
|
||||||
|
nicht (mehr) laeuft oder unbekannt ist."""
|
||||||
|
job = JOBS.get(job_id)
|
||||||
|
if not job or job["state"] in ("done", "failed", "canceled"):
|
||||||
|
return False
|
||||||
|
job["canceled"] = True
|
||||||
|
_append_log(job, "[mission-control] Abbruch angefordert…")
|
||||||
|
proc = _PROCS.get(job_id)
|
||||||
|
if proc is not None:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Prozess noch nicht gestartet -> direkt als abgebrochen markieren.
|
||||||
|
job["state"] = "canceled"
|
||||||
|
job["finished_at"] = time.time()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def start_job(args: list[str], label: str, env: dict | None = None,
|
def start_job(args: list[str], label: str, env: dict | None = None,
|
||||||
stdin_data: str | None = None, log_cmd: str | None = None) -> str:
|
stdin_data: str | None = None, log_cmd: str | None = None) -> str:
|
||||||
job_id = uuid.uuid4().hex[:12]
|
job_id = uuid.uuid4().hex[:12]
|
||||||
|
|||||||
+8
-1
@@ -6,7 +6,7 @@ Liefert die Daten fuer das Aktivitaets-Panel mit Live-Log.
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from auth import auth
|
from auth import auth
|
||||||
from jobengine import JOBS
|
from jobengine import JOBS, cancel_job
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
||||||
|
|
||||||
@@ -19,6 +19,13 @@ def job_status(job_id: str):
|
|||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/cancel")
|
||||||
|
def job_cancel(job_id: str):
|
||||||
|
if not cancel_job(job_id):
|
||||||
|
raise HTTPException(409, "Job laeuft nicht (mehr) oder ist unbekannt.")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/jobs")
|
@router.get("/jobs")
|
||||||
def jobs_list():
|
def jobs_list():
|
||||||
return sorted(JOBS.values(), key=lambda j: j["started_at"], reverse=True)[:20]
|
return sorted(JOBS.values(), key=lambda j: j["started_at"], reverse=True)[:20]
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// jobs.js — Aktivität (v3): Live-System-Metriken + Hintergrund-Jobs mit Log.
|
// jobs.js — Aktivität (v3): Live-System-Metriken + Hintergrund-Jobs mit Log.
|
||||||
// Exportiert track(id), damit andere Panels einen Job auto-aufklappen.
|
// Exportiert track(id), damit andere Panels einen Job auto-aufklappen.
|
||||||
|
|
||||||
import { $, esc, fmtBytes } from "../core/ui.js";
|
import { api } from "../core/api.js";
|
||||||
|
import { $, esc, fmtBytes, toast } from "../core/ui.js";
|
||||||
|
|
||||||
const tracked = new Set();
|
const tracked = new Set();
|
||||||
let JOBS = [];
|
let JOBS = [];
|
||||||
@@ -12,8 +13,21 @@ export function track(id) { tracked.add(id); renderJobs(); }
|
|||||||
const hist = { cpu: [], ram: [], gpu: [] };
|
const hist = { cpu: [], ram: [], gpu: [] };
|
||||||
const MAX_HIST = 60;
|
const MAX_HIST = 60;
|
||||||
|
|
||||||
function statusBadge(s) { return s === "done" ? '<span class="badge b-run">fertig</span>' : s === "failed" ? '<span class="badge b-err">fehler</span>' : '<span class="badge b-load">läuft…</span>'; }
|
function statusBadge(s) { return s === "done" ? '<span class="badge b-run">fertig</span>' : s === "failed" ? '<span class="badge b-err">fehler</span>' : s === "canceled" ? '<span class="badge">abgebrochen</span>' : '<span class="badge b-load">läuft…</span>'; }
|
||||||
function dotClass(s) { return s === "done" ? "on" : s === "failed" ? "" : "load"; }
|
function dotClass(s) { return s === "done" ? "on" : (s === "failed" || s === "canceled") ? "" : "load"; }
|
||||||
|
|
||||||
|
// Fortschritt in % aus der letzten Log-Zeile ziehen (tqdm schreibt z.B. " 45%|…").
|
||||||
|
function jobPct(j) {
|
||||||
|
if (j.state !== "running") return null;
|
||||||
|
const last = (j.log || [])[j.log.length - 1] || "";
|
||||||
|
const m = last.match(/(\d+(?:\.\d+)?)\s*%/);
|
||||||
|
return m ? Math.min(100, parseFloat(m[1])) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelJob(id) {
|
||||||
|
try { await api(`/api/jobs/${id}/cancel`, { method: "POST" }); toast("Abbruch angefordert…"); renderJobs(); }
|
||||||
|
catch (e) { toast(e.message, true); }
|
||||||
|
}
|
||||||
|
|
||||||
function tile(label, value, sub) {
|
function tile(label, value, sub) {
|
||||||
return `<div class="tile"><div class="t-l">${label}</div><div class="t-v">${value}</div><div class="t-s">${sub}</div></div>`;
|
return `<div class="tile"><div class="t-l">${label}</div><div class="t-v">${value}</div><div class="t-s">${sub}</div></div>`;
|
||||||
@@ -76,6 +90,8 @@ function mount() {
|
|||||||
<div id="jobs-empty" class="empty-c"><div class="e-t">Gerade nichts los.</div><div class="e-s">Alles ruhig — keine laufenden Aufgaben.</div></div>`;
|
<div id="jobs-empty" class="empty-c"><div class="e-t">Gerade nichts los.</div><div class="e-s">Alles ruhig — keine laufenden Aufgaben.</div></div>`;
|
||||||
|
|
||||||
$("#v-activity").addEventListener("click", e => {
|
$("#v-activity").addEventListener("click", e => {
|
||||||
|
const cancel = e.target.closest("[data-cancel]");
|
||||||
|
if (cancel) { e.stopPropagation(); cancelJob(cancel.getAttribute("data-cancel")); return; }
|
||||||
const h = e.target.closest(".job-h"); if (!h) return;
|
const h = e.target.closest(".job-h"); if (!h) return;
|
||||||
const id = h.getAttribute("data-id");
|
const id = h.getAttribute("data-id");
|
||||||
tracked.has(id) ? tracked.delete(id) : tracked.add(id);
|
tracked.has(id) ? tracked.delete(id) : tracked.add(id);
|
||||||
@@ -91,8 +107,14 @@ function renderJobs() {
|
|||||||
$("#job-count").textContent = JOBS.length ? (failed ? failed + " Fehler" : JOBS.length + " gesamt") : "";
|
$("#job-count").textContent = JOBS.length ? (failed ? failed + " Fehler" : JOBS.length + " gesamt") : "";
|
||||||
c.innerHTML = JOBS.map(j => {
|
c.innerHTML = JOBS.map(j => {
|
||||||
const log = tracked.has(j.id) ? `<div class="log">${esc((j.log || []).join("\n"))}</div>` : "";
|
const log = tracked.has(j.id) ? `<div class="log">${esc((j.log || []).join("\n"))}</div>` : "";
|
||||||
|
const p = jobPct(j);
|
||||||
|
const prog = p != null ? `<div class="bar" style="margin-top:8px"><i style="width:${p}%"></i></div>` : "";
|
||||||
|
const pctTxt = p != null ? `<span class="mono-sm" style="margin-left:auto">${Math.round(p)} %</span>` : "";
|
||||||
|
const cancelBtn = j.state === "running"
|
||||||
|
? `<button class="ghost" data-cancel="${esc(j.id)}" style="${p != null ? "" : "margin-left:auto;"}margin-right:2px;padding:2px 9px;font-size:12px">Abbrechen</button>`
|
||||||
|
: "";
|
||||||
return `<div class="job"><div class="job-h" data-id="${esc(j.id)}">
|
return `<div class="job"><div class="job-h" data-id="${esc(j.id)}">
|
||||||
<span class="li-dot ${dotClass(j.state)}"></span><span class="mid">${esc(j.label)}</span>${statusBadge(j.state)}</div>${log}</div>`;
|
<span class="li-dot ${dotClass(j.state)}"></span><span class="mid">${esc(j.label)}</span>${pctTxt}${cancelBtn}${statusBadge(j.state)}</div>${prog}${log}</div>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,11 @@ function mount() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="w-update-card" style="display:none">
|
<div class="card" id="w-update-card" style="display:none">
|
||||||
<div class="card-h"><h3>Aktueller Vorgang</h3><span id="w-update-badge" style="margin-left:auto"></span></div>
|
<div class="card-h"><h3>Aktueller Vorgang</h3>
|
||||||
|
<button class="ghost" id="w-update-cancel" style="margin-left:auto;display:none;padding:3px 12px">Abbrechen</button>
|
||||||
|
<span id="w-update-badge" style="margin-left:10px"></span></div>
|
||||||
<div class="card-sub" id="w-update-label">—</div>
|
<div class="card-sub" id="w-update-label">—</div>
|
||||||
|
<div id="w-update-bar" class="bar" style="display:none;margin-bottom:10px"><i style="width:0%"></i></div>
|
||||||
<div id="w-update-log" class="log" style="max-height:280px"></div>
|
<div id="w-update-log" class="log" style="max-height:280px"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,9 +70,17 @@ function mount() {
|
|||||||
$("#w-os-update").addEventListener("click", osUpdate);
|
$("#w-os-update").addEventListener("click", osUpdate);
|
||||||
$("#w-reboot").addEventListener("click", rebootServer);
|
$("#w-reboot").addEventListener("click", rebootServer);
|
||||||
$("#w-console-sel").addEventListener("change", connectConsole);
|
$("#w-console-sel").addEventListener("change", connectConsole);
|
||||||
|
$("#w-update-cancel").addEventListener("click", cancelActive);
|
||||||
connectConsole();
|
connectConsole();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cancelActive() {
|
||||||
|
if (!activeJob) return;
|
||||||
|
if (!await confirmModal({ title: activeJob.label + " abbrechen?", body: "Der laufende Vorgang wird gestoppt. Bei einem Download bleibt eine unvollständige Datei zurück (kannst du später neu starten).", confirmLabel: "Abbrechen", danger: true })) return;
|
||||||
|
try { await api(`/api/jobs/${activeJob.id}/cancel`, { method: "POST" }); toast("Abbruch angefordert…"); }
|
||||||
|
catch (e) { toast(e.message, true); }
|
||||||
|
}
|
||||||
|
|
||||||
let ws = null;
|
let ws = null;
|
||||||
function connectConsole() {
|
function connectConsole() {
|
||||||
if (ws) { ws.close(); ws = null; }
|
if (ws) { ws.close(); ws = null; }
|
||||||
@@ -122,16 +133,32 @@ function onJobs(jobs) {
|
|||||||
card.style.display = "";
|
card.style.display = "";
|
||||||
const logEl = $("#w-update-log");
|
const logEl = $("#w-update-log");
|
||||||
if (logEl) { logEl.textContent = (j.log || []).join("\n"); logEl.scrollTop = logEl.scrollHeight; }
|
if (logEl) { logEl.textContent = (j.log || []).join("\n"); logEl.scrollTop = logEl.scrollHeight; }
|
||||||
|
const done = j.state === "done", failed = j.state === "failed", canceled = j.state === "canceled";
|
||||||
|
const terminal = done || failed || canceled;
|
||||||
const badge = $("#w-update-badge");
|
const badge = $("#w-update-badge");
|
||||||
const done = j.state === "done", failed = j.state === "failed";
|
|
||||||
if (badge) badge.innerHTML = done ? '<span class="badge b-run">✓ fertig</span>'
|
if (badge) badge.innerHTML = done ? '<span class="badge b-run">✓ fertig</span>'
|
||||||
: failed ? '<span class="badge b-err">✗ fehlgeschlagen</span>'
|
: failed ? '<span class="badge b-err">✗ fehlgeschlagen</span>'
|
||||||
|
: canceled ? '<span class="badge">abgebrochen</span>'
|
||||||
: '<span class="badge b-load">läuft…</span>';
|
: '<span class="badge b-load">läuft…</span>';
|
||||||
if (done || failed) {
|
|
||||||
$("#w-update-label").textContent = activeJob.label + (done ? " — erfolgreich abgeschlossen." : " — fehlgeschlagen (siehe Protokoll).");
|
// Fortschrittsbalken aus der letzten Log-Zeile (tqdm: " 45%|…")
|
||||||
|
const last = (j.log || [])[j.log.length - 1] || "";
|
||||||
|
const pm = !terminal && last.match(/(\d+(?:\.\d+)?)\s*%/);
|
||||||
|
const bar = $("#w-update-bar");
|
||||||
|
if (bar) {
|
||||||
|
if (pm) { bar.style.display = ""; bar.querySelector("i").style.width = Math.min(100, parseFloat(pm[1])) + "%"; }
|
||||||
|
else bar.style.display = "none";
|
||||||
|
}
|
||||||
|
$("#w-update-cancel").style.display = terminal ? "none" : "";
|
||||||
|
|
||||||
|
if (terminal) {
|
||||||
|
$("#w-update-label").textContent = activeJob.label +
|
||||||
|
(done ? " — erfolgreich abgeschlossen." : canceled ? " — abgebrochen." : " — fehlgeschlagen (siehe Protokoll).");
|
||||||
if (!notified.has(j.id)) {
|
if (!notified.has(j.id)) {
|
||||||
notified.add(j.id);
|
notified.add(j.id);
|
||||||
toast(done ? "✓ " + activeJob.label + " abgeschlossen." : "✗ " + activeJob.label + " fehlgeschlagen.", failed);
|
toast(done ? "✓ " + activeJob.label + " abgeschlossen."
|
||||||
|
: canceled ? activeJob.label + " abgebrochen."
|
||||||
|
: "✗ " + activeJob.label + " fehlgeschlagen.", failed);
|
||||||
if (done) setTimeout(refreshSoon, 800);
|
if (done) setTimeout(refreshSoon, 800);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user