diff --git a/docker/api/main.py b/docker/api/main.py index 318b5f7..a0e6175 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -121,11 +121,14 @@ async def startup_event(): ereignis_waechter = Waechter( db, ereignis_bus, - laufwerke_lesen=lambda: [ - device_discovery.device_info(p) - for p in device_discovery.list_optical_devices() - ], + # MIT der erkannten Disc — sonst meldet die Server-Status-Kachel + # „kein Datentraeger", waehrend die Disc erkannt ist (29.08.2026). + laufwerke_lesen=laufwerke_mit_disc, system_lesen=system_stand_sync, + # DIESELBE Uebersetzung wie /jobs — sonst tragen die Live-Ereignisse + # andere Feldnamen als der Schnappschuss (Befund 29.08.2026: + # „1.1.1970" und „DISC" in der Jobliste). + job_form=lambda z: _job_row_to_model(z).model_dump(), ) asyncio.create_task(ereignis_waechter.schleife()) @@ -442,12 +445,14 @@ async def _snapshot() -> dict: # Laufwerke getrennt: device_info macht ioctls, die hängen können — # ein defektes Laufwerk darf den Snapshot nicht aufhalten. try: + # MIT der erkannten Disc. Hier stand dieselbe Schleife ein DRITTES + # Mal (Befund 29.08.2026) — `/devices` hängte die Disc an, der + # Ereignis-Wächter und dieser Schnappschuss nicht. Das Dashboard + # liest genau diesen Schnappschuss und schloss aus der fehlenden + # Disc auf ein leeres Laufwerk: „Bereit — kein Datenträger im + # Laufwerk", während der Titel eine Zeile weiter oben stand. zustand["devices"] = await asyncio.wait_for( - asyncio.to_thread( - lambda: [device_discovery.device_info(p) - for p in device_discovery.list_optical_devices()]), - timeout=5, - ) + asyncio.to_thread(laufwerke_mit_disc), timeout=5) except (asyncio.TimeoutError, OSError): # „konnte nicht nachsehen" ist etwas anderes als „es gibt keine". # Deshalb None und nicht [] — das UI behält dann seinen alten Stand. @@ -2363,6 +2368,12 @@ async def system_info(): continue info["plaetze"].append({ "name": ort["name"], + # Der PFAD gehoert dazu (Commander 29.08.2026): „bei ‚Platz + # fuer rippy' sollte eher das arbeitsverzeichnis und der + # Ablagepfad sein." Eine Zahl ohne Ort sagt nicht, WO der + # Platz knapp wird. + "pfad": ort["pfad"], + "gleiches_laufwerk": bool(ort.get("gleiches_laufwerk")), "frei_gb": round(nutzung.free / 1024**3, 1), "gesamt_gb": round(nutzung.total / 1024**3, 1), }) @@ -2723,13 +2734,37 @@ async def get_devices(): prinzipbedingt nichts: kein udevd, keine udev-Datenbank, kein Daemon, der Symlinks anlegt. Jetzt: /sys fürs Modell, ioctl für den Disc-Status. """ + return [Device(**info) for info in + await asyncio.to_thread(laufwerke_mit_disc)] + + +def laufwerke_mit_disc() -> list: + """Laufwerke SAMT erkannter Disc — der eine Weg für beide Abnehmer. + + ## Der Befund des Commanders (29.08.2026) + + > „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht" + + Auf der Server-Status-Kachel stand „Bereit — kein Datenträger im + Laufwerk", während die Disc erkannt war und ihr Titel eine Zeile weiter + oben in der Jobliste stand. + + Die Ursache: Es gab ZWEI Wege zu den Laufwerken. `/devices` hängte die + erkannte Disc aus `DISC_CACHE` an; der Ereignis-Wächter rief nur + `device_info()` und ließ sie weg. Das UI liest seit V2-3 den Ereignisstrom + — also die Fassung ohne Disc. Es fragte `l.disc?.title`, bekam nichts, und + schloss daraus auf ein leeres Laufwerk. + + Eine Auskunft in zwei Fassungen ist eine Auskunft zu viel. Jetzt gibt es + nur diese hier. + """ geraete = [] for pfad in device_discovery.list_optical_devices(): - info = await asyncio.to_thread(device_discovery.device_info, pfad) + info = device_discovery.device_info(pfad) disc = DISC_CACHE.get(pfad) if disc and not disc.get("_laeuft"): info["disc"] = disc - geraete.append(Device(**info)) + geraete.append(info) return geraete diff --git a/docker/api/test_api_smoke.py b/docker/api/test_api_smoke.py index 87cff29..025b049 100644 --- a/docker/api/test_api_smoke.py +++ b/docker/api/test_api_smoke.py @@ -411,3 +411,130 @@ def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen(): # Ein Hinweis auf docker compose darf NUR im Container erscheinen. if not daten["im_container"]: assert daten["hilfe_befehl"] == "" + + +# ── Die Grenze DB → Oberflaeche (Befund 29.08.2026) ───────────────────── +# +# Commander mit Bildschirmfoto: „schau mal hier, das Datum ist falsch." +# +# TYP „DISC" STARTZEIT „1.1.1970, 01:00:00" STATUS „running" +# +# Der Waechter schickte die ROHE Datenbankzeile ueber den Ereignisstrom. Dort +# heissen die Spalten `disc_type` und `created_at`, im UI aber `type` und +# `startTime` — die Uebersetzung macht `_job_row_to_model`, und sie bildet +# auch `running` auf `processing` ab. +# +# Der Test, den ich beim ersten Anlauf geschrieben habe, hat das NICHT +# gefunden: Ich hatte seine Beispielzeile selbst erfunden, mit meinen +# falschen Feldnamen. Ein Test, der dieselbe Annahme macht wie der Code, +# prueft nichts. Dieser hier geht von der ECHTEN Zeile aus. + + +def _echte_db_zeile(): + """Eine Zeile mit den Spaltennamen, die wirklich in der Datenbank stehen.""" + from datetime import datetime + + return { + "id": "j1", + "disc_type": "bluray", # NICHT "type" + "created_at": datetime(2026, 8, 29, 12, 7, 2), # NICHT "startTime" + "finished_at": None, + "status": "running", # wird zu "processing" + "device": r"\.\G:", + "progress": 12, + "title": "Evangelion 2.22", + "error": None, + "meta": None, + } + + +def test_die_umwandlung_liefert_die_namen_die_das_ui_kennt(): + import main + + ui = main._job_row_to_model(_echte_db_zeile()).model_dump() + assert ui["type"] == "bluray" + assert ui["startTime"].startswith("2026-08-29") + assert ui["status"] == "processing", "der Worker-Status heisst im UI anders" + + +def test_das_live_ereignis_traegt_dieselben_namen_wie_die_jobliste(): + """DER Waechter gegen „1.1.1970". Ohne die Umwandlung sind alle + Pflichtfelder leer — und leer sieht im Browser aus wie eine Auskunft.""" + import main + from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz + + ui = main._job_row_to_model(_echte_db_zeile()).model_dump() + kurz = _job_kurz(ui) + for feld in UI_PFLICHTFELDER: + assert kurz.get(feld) is not None, "%s waere im Browser leer" % feld + assert kurz["type"] == "bluray" + assert kurz["status"] == "processing" + + +def test_der_waechter_ist_mit_der_umwandlung_verdrahtet(): + """Sonst waere die Reparatur wieder nur eine Moeglichkeit, keine Tatsache.""" + import inspect + + import main + + quelle = inspect.getsource(main.startup_event) + assert "job_form=" in quelle, "Waechter bekommt die Umwandlung nicht" + assert "_job_row_to_model" in quelle + + +def test_die_laufwerksliste_kommt_aus_EINER_quelle(): + """Waechter gegen die vierte Kopie (Befund 29.08.2026). + + Commander: „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht." + + Die Laufwerksliste wurde an DREI Stellen gebaut: im `/devices`-Endpunkt + (mit angehaengter Disc), im Ereignis-Waechter und im SSE-Schnappschuss + (beide ohne). Das Dashboard liest den Schnappschuss — und schloss aus der + fehlenden Disc auf ein leeres Laufwerk, waehrend ihr Titel eine Zeile + weiter oben stand. + + Eine Auskunft in drei Fassungen ist zwei Fassungen zu viel. + """ + import inspect + + import main + + quelle = inspect.getsource(main) + # Die eine erlaubte Fundstelle ist die Funktion selbst. + stellen = quelle.count("device_discovery.device_info(") + assert stellen == 1, ( + "device_info wird an %d Stellen aufgerufen — die Liste gehoert in " + "laufwerke_mit_disc(), sonst fehlt irgendwo die Disc" % stellen) + for name in ("_snapshot", "startup_event", "get_devices"): + text = inspect.getsource(getattr(main, name)) + assert "device_info(" not in text, \ + "%s baut die Laufwerksliste selbst" % name + + +def test_laufwerke_mit_disc_haengt_die_erkannte_disc_an(monkeypatch): + import main + + monkeypatch.setattr(main.device_discovery, "list_optical_devices", + lambda: [r"\.\G:"]) + monkeypatch.setattr(main.device_discovery, "device_info", + lambda p: {"id": "G", "name": "Laufwerk G:", "path": p, + "type": "bluray", "status": "ready"}) + monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"}) + + geraete = main.laufwerke_mit_disc() + assert geraete[0]["disc"]["title"] == "Evangelion 2.22" + + +def test_eine_noch_laufende_erkennung_wird_NICHT_als_disc_gemeldet(monkeypatch): + """`_laeuft` heisst „wird gerade erkannt" — das ist noch keine Auskunft.""" + import main + + monkeypatch.setattr(main.device_discovery, "list_optical_devices", + lambda: [r"\.\G:"]) + monkeypatch.setattr(main.device_discovery, "device_info", + lambda p: {"id": "G", "name": "Laufwerk G:", "path": p, + "type": "bluray", "status": "ready"}) + monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", + {"_laeuft": True, "title": "Wird erkannt…"}) + + assert "disc" not in main.laufwerke_mit_disc()[0] diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 08f6ae0..7d6a885 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -44,7 +44,8 @@ interface Job { interface SystemInfo { api_version: string - plaetze: { name: string; frei_gb: number; gesamt_gb: number }[] + plaetze: { name: string; pfad?: string; gleiches_laufwerk?: boolean; + frei_gb: number; gesamt_gb: number }[] workers: { name: string; encoders: string[] }[] } @@ -584,6 +585,38 @@ export default function Dashboard() { Details + {/* + Abbrechen DIREKT in der Zeile (Commander + 29.08.2026: „man kann einen rip garnicht + abbrechen"). + + Es gab genau einen Abbrechen-Knopf, und der + steckte in der Kachel „laufender Job". Die + erschien nur bei Status `processing` — der + Ereignisstrom lieferte aber den rohen + DB-Status `running`. Also keine Kachel, also + kein Knopf, also kein Weg zurueck. + Die Ursache ist behoben; ein zweiter Weg zum + Abbrechen ist trotzdem richtig, denn genau + hier sucht man ihn. + */} + {['processing', 'transcoding', 'pending'].includes(job.status) && ( + + )} + + {job.status === 'canceling' && ( + bricht ab … + )} + {job.status === 'completed' && (