Zweite Etappe. Kern: das Frontend traegt kein Geheimnis mehr, und es laeuft
nicht laenger als einziger Teil des Stacks ungeprueft durchs Gate.
## B-01 — Box-Sudo-Passwort: ersatzlos entfernt
Das Passwort lag im localStorage und reiste bei JEDEM mutierenden Request mit —
als Header `X-Sudo-Password` UND im JSON-Rumpf. Da /etc/sudoers den Dienst-Nutzer
mit `NOPASSWD: ALL` fuehrt, waere ein einziger XSS in der SPA Root auf der Box
gewesen.
AUF DER BOX GEMESSEN: `sudo -n true` laeuft durch. Das Passwort wurde also nie
gebraucht — es war reines Risiko ohne Gegenwert. Darum keine Umkonstruktion
(Sitzungs-Cookie o. AE.), sondern Loeschung, quer durch den ganzen Pfad:
frontend/src/lib/api.ts kein localStorage-Zugriff mehr
frontend/.../SettingsTab.tsx Eingabefeld weg, dafuer die Erklaerung warum
backend/routers/maintenance.py SudoReq entfaellt, 8 Endpunkte entschlackt
backend/services/maintenance.py _run() nutzt immer `sudo -n`
backend/services/jobengine.py keine stdin-Pipe mehr (DEVNULL)
`password_required` bleibt als ehrliches Signal: Verlangt sudo je doch ein
Passwort, ist das eine Konfigurations-Frage auf der Box — nichts, was man mit
einem im Browser geparkten Geheimnis uebertuencht.
## HuggingFace-Token: liegt jetzt auf der Box
Derselbe Fehler, kleinerer Radius. Neu: backend/services/geheimnisse.py — Datei
neben den anderen mc2-*.json, Rechte 0600, atomar geschrieben. Die Oberflaeche
erfaehrt nur, OB ein Token gesetzt ist, nie seinen Wert. Ein Schluessel-Allowlist
verhindert, dass ein fehlgeleiteter Request beliebige Felder hineinschreibt.
Prozess-Env (HF_TOKEN) hat Vorrang und wird als solche angezeigt.
Verifiziert gegen das lokale Backend: setzen/lesen/loeschen ok, unerlaubter
Schluessel wird mit Klartext-Grund abgewiesen, der Wert kommt nie zurueck.
## B-14 — Fehlermeldungen sagen jetzt, was los ist
api() warf `new Error("500 Internal Server Error")` und verwarf den Rumpf; der
eigentliche Grund aus FastAPIs `detail` erreichte die Oberflaeche nie. Neu:
ApiError mit status + detail, inklusive Validierungslisten und HTML-Fehlerseiten
(ein kaputter Rumpf darf die Meldung nicht in einen zweiten Fehler verwandeln).
Zwei Aufrufstellen zeigen den Grund jetzt statt "Fehler" (Discover, ModelBrowse).
## B-07 — Tests und Linter, ehrlich eingeordnet
Praezisierung gegenueber dem Audit: Die MC2-Ampel fuehrt bewusst GAR KEINE Tests
aus (dokumentiert: die Python-Dienste haengen an ML-Wheels, die echten Tests sind
Pruefstand + Box). Das ist fuer die Dienste richtig — fuer Frontend-Unit-Tests
nicht: die laufen in jsdom, brauchen weder Modell noch GPU, und sind in 1,3 s durch.
Vitest + Testing Library, 17 Tests in 3 Dateien
ESLint (flat config) + Prettier
Beides jetzt Teil der Ampel
Die Tests sind kein Feigenblatt: acht davon sind der Zaun um B-01 — sie beweisen,
dass api() weder Kopfzeilen noch Rumpf aus dem localStorage anreichert. Dazu eine
ESLint-Regel, die localStorage-Zugriffe auf Schluessel mit password/token/secret
im Namen hart abweist (an einer Probe verifiziert; harmlose Schluessel wie
mc_sidebar_collapsed bleiben erlaubt).
ESLint meldet 0 Fehler / 93 Warnungen. Die 18 Treffer der neuen React-Compiler-
Regeln (setState im Effekt, Ref-Zugriff im Render) sind ECHT, aber quer durch
10 500 Zeilen zu beheben ist P5-Arbeit. Sie stehen als sichtbare Warn-Liste statt
abgeschaltet — ein ab Tag eins rotes Gate ist kein Gate mehr.
## Nebenbefund, im Browser reproduziert: veraltetes Buendel nach Deploy
Ein Deploy ersetzt dist und startet den Dienst neu; offene Tabs behalten aber ihr
altes Start-Buendel, dessen Nachlade-Chunks nun fehlen — der naechste
Ansichtswechsel wirft. Galt schon fuer die 10 lazy Views, traf durch P0 nun auch
die Startseite. lib/veralteteVersion.ts faengt Vites `vite:preloadError` ab und
laedt EINMAL neu (Sperre in sessionStorage gegen Endlosschleife).
## Nachgemessen
Start-Chunk gzip 111 411 B · dist gesamt 1 480 097 B (beide im Ampel-Budget)
tsc --noEmit sauber · 17/17 Tests gruen · ESLint 0 Fehler
Im echten Browser gegen das lokale Backend geprueft: Einstellungen holen den
Token-Zustand von der Box, kein Passwort-Feld mehr, Knoepfe korrekt gesperrt,
beide Cockpit-Diagramme rendern (Achsen + Zeitachse sichtbar).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
185 lines
6.1 KiB
Python
185 lines
6.1 KiB
Python
"""
|
|
Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt.
|
|
Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job.
|
|
"""
|
|
|
|
import glob
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
import uuid
|
|
|
|
JOBS: dict[str, dict] = {}
|
|
_PROCS: dict[str, subprocess.Popen] = {}
|
|
_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 byteweise; `\\r` (tqdm/hf-Fortschritt) überschreibt die letzte Zeile."""
|
|
buf = b""
|
|
overwrite = False
|
|
pending_cr = False
|
|
|
|
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":
|
|
commit(); overwrite = False; buf = b""
|
|
continue
|
|
commit(); overwrite = True; buf = b""
|
|
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):
|
|
"""Job-Prozess starten und mitschreiben.
|
|
|
|
v3-Umbau P1 (28.08.2026): Hier wurde frueher ein Sudo-Passwort aus dem Browser an
|
|
stdin gefuettert (und dafuer `sudo -n` in den Argumenten zu `sudo -S` umgeschrieben).
|
|
Auf der Box laeuft sudo passwortlos (`NOPASSWD: ALL`), der Pfad war tot. Ohne ihn
|
|
braucht der Prozess auch keine stdin-Pipe mehr: DEVNULL sorgt dafuer, dass ein Job,
|
|
der wider Erwarten nach einem Passwort fragt, sofort scheitert statt still zu haengen."""
|
|
job = JOBS[job_id]
|
|
job["state"] = "running"
|
|
try:
|
|
proc = subprocess.Popen(
|
|
list(args), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
stdin=subprocess.DEVNULL,
|
|
bufsize=0,
|
|
env={**os.environ, **(env or {})},
|
|
)
|
|
_PROCS[job_id] = proc
|
|
|
|
_pump_output(job, proc.stdout)
|
|
proc.wait()
|
|
job["returncode"] = proc.returncode
|
|
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
|
|
except Exception as exc:
|
|
_append_log(job, f"[mc] Fehler: {exc}")
|
|
job["state"] = "failed"
|
|
job["returncode"] = -1
|
|
finally:
|
|
_PROCS.pop(job_id, None)
|
|
job["finished_at"] = time.time()
|
|
cb = job.pop("_on_done", None)
|
|
if cb and job["state"] == "done":
|
|
try:
|
|
cb()
|
|
except Exception as exc:
|
|
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
|
|
|
|
|
|
def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None:
|
|
"""Fortschritt in % aus wachsenden *.incomplete-Dateien (hf schreibt sie)."""
|
|
if not total_bytes or total_bytes <= 0:
|
|
return
|
|
job = JOBS.get(job_id)
|
|
if job is not None:
|
|
job["progress"] = 0
|
|
job["total_bytes"] = total_bytes
|
|
|
|
def _watch():
|
|
pat = os.path.join(local_dir, ".cache", "huggingface", "download", "**", "*.incomplete")
|
|
prev_t = prev_b = None
|
|
rate = 0.0
|
|
while True:
|
|
j = JOBS.get(job_id)
|
|
if not j or j["state"] in ("done", "failed", "canceled"):
|
|
break
|
|
try:
|
|
inc = glob.glob(pat, recursive=True)
|
|
cur = sum(os.path.getsize(f) for f in inc) if inc else 0
|
|
if cur:
|
|
j["progress"] = min(99, int(cur * 100 / total_bytes))
|
|
j["done_bytes"] = cur
|
|
now = time.time()
|
|
if prev_t is not None and now > prev_t and cur >= prev_b:
|
|
inst = (cur - prev_b) / (now - prev_t)
|
|
rate = inst if rate == 0 else 0.3 * inst + 0.7 * rate
|
|
if rate > 0:
|
|
j["rate_bps"] = rate
|
|
j["eta_s"] = int((total_bytes - cur) / rate)
|
|
prev_t, prev_b = now, cur
|
|
except Exception:
|
|
pass
|
|
time.sleep(1.0)
|
|
j = JOBS.get(job_id)
|
|
if j and j["state"] == "done":
|
|
j["progress"] = 100
|
|
j.pop("eta_s", None)
|
|
|
|
threading.Thread(target=_watch, daemon=True).start()
|
|
|
|
|
|
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
|
group: str | None = None) -> str:
|
|
job_id = uuid.uuid4().hex[:12]
|
|
log_args = list(args)
|
|
JOBS[job_id] = {
|
|
"id": job_id, "label": label, "state": "queued", "group": group,
|
|
"log": ["$ " + " ".join(shlex.quote(a) for a in log_args)],
|
|
"returncode": None, "started_at": time.time(), "finished_at": None,
|
|
}
|
|
if on_done:
|
|
JOBS[job_id]["_on_done"] = on_done
|
|
threading.Thread(target=_run_job, args=(job_id, args, env), daemon=True).start()
|
|
return job_id
|
|
|
|
|
|
def active_in_group(group: str) -> dict | None:
|
|
"""Erster laufender/wartender Job einer Gruppe (z.B. 'maintenance'), sonst None.
|
|
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
|
|
for j in JOBS.values():
|
|
if j.get("group") == group and j.get("state") in ("running", "queued"):
|
|
return j
|
|
return None
|
|
|
|
|
|
def cancel_job(job_id: str) -> bool:
|
|
job = JOBS.get(job_id)
|
|
if not job or job["state"] in ("done", "failed", "canceled"):
|
|
return False
|
|
job["canceled"] = True
|
|
_append_log(job, "[mc] Abbruch angefordert…")
|
|
proc = _PROCS.get(job_id)
|
|
if proc is not None:
|
|
try:
|
|
proc.terminate()
|
|
except Exception:
|
|
pass
|
|
else:
|
|
job["state"] = "canceled"
|
|
job["finished_at"] = time.time()
|
|
return True
|
|
|
|
|
|
def public_jobs() -> list[dict]:
|
|
"""Jobs ohne interne Felder (_on_done) für die API."""
|
|
return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()]
|