feat(ui): die Oberflaeche weiss jetzt, worauf sie laeuft

Commander-Befund 28.08.2026, zum Windows-Fenster:

    Worker erreichbar:   0 von 1
    Kein Worker antwortet — Pruefen: docker compose ps
    Container-Platte:    unbekannt
    Freigaben:           keine eingehaengt

Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Sein Urteil: "Du hast
ja quasi nur rippy genommen und die docker installation fuer Windows gebaut.
Das gilt fuer die ganze standalone version fuer Windows, auch fuer die
settings und die Anleitung usw."

## Die Ursache war nicht die Anzeige

Die naheliegende Reparatur waere ein `if (windows)` an dreissig Stellen
gewesen -- dieselbe Falle noch einmal, nur mit einer zweiten Sorte Vermutung.

Es fehlte etwas anderes: Das UI hat nie erfahren, worauf es laeuft. config.py
kennt das Profil seit V2-2, weitergegeben wurde es nie. Also hat das UI
angenommen.

Neu: GET /betrieb meldet FAEHIGKEITEN, keinen Modus-Namen.

    externe_worker        Gibt es andere Maschinen, die Jobs uebernehmen?
    freigaben_einhaengen  Kann Rippy Netzwerk-Freigaben selbst einhaengen?
    container_pfade       Sind Pfade wie /app/media ueberhaupt gemeint?
    werkzeuge_verwalten   Kann Rippy MakeMKV/HandBrake selbst beschaffen?

Ein Modus-Name wuerde das UI zwingen, aus einem Namen auf Verhalten zu
schliessen -- und das bricht beim naechsten Betriebsfall: Ein
Docker-All-in-One hat Container-Pfade, aber keinen zweiten Worker.

## Was sich sichtbar aendert (auf Windows nachgemessen)

    Server-Status  "Rippy arbeitet: auf diesem Rechner" statt Worker-Zaehler
                   "Platz fuer Rippy: 59.2 von 232 GB frei" statt "unbekannt"
                   Freigaben-Block faellt weg
    Einstellungen  kein Reiter "Worker"; Speicherziele BLEIBT (dort steht die
                   Ablage -- "wo will ich das hinspeichern" war die Frage),
                   aber mit Pfadfeld statt Container-Auswahl und ohne die
                   Maske zum Einhaengen
    Anleitung      kein docker, kein Encoding-Worker-Abschnitt, stattdessen
                   der echte Ordner (C:\Users\...\Videos\Rippy)

## Zwei Fehler, die dabei aufgefallen sind

* MEDIA_ROOT = "/app/media" war in main.py fest verdrahtet. shutil.disk_usage
  warf unter Windows, die Liste blieb leer -- daher "unbekannt", obwohl auf
  dem Laufwerk 59 GB frei waren. Eine Nichtauskunft, die wie eine Auskunft
  aussieht. platz_orte() liefert die Orte jetzt je Betrieb, und ein noch
  nicht angelegter Ordner faellt auf das naechste vorhandene Elternteil
  zurueck.
* Der SSE-Schnappschuss enthielt den Server-Zustand NICHT, und der Waechter
  schickt ihn nur alle 15 Sekunden. Nach jedem Neuladen stand deshalb bis zu
  eine Viertelminute "unbekannt" da. Jetzt ist er im Schnappschuss, und das
  UI uebernimmt ihn auch von dort.

Dazu: der Windows-Skip in test_api_smoke.py ist weg. Er stammte aus der Zeit
vor V2-4, als main.py fcntl brauchte; seit der Treiberwahl ueber den Port
laedt es auf beiden Plattformen (57 Routen, gemessen). Damit laufen 20 Tests
mehr auch lokal statt nur auf der Ampel.

Ampel lokal: 654 gruen, ruff sauber. Docker-Zweig durch Unit-Tests gedeckt,
am echten Container noch nicht gegengeprueft -- das kommt beim Deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 13:45:00 +02:00
co-authored by Claude Opus 5
parent a5e64f8e56
commit 66642b8d93
10 changed files with 899 additions and 53 deletions
+101 -8
View File
@@ -453,9 +453,45 @@ async def _snapshot() -> dict:
# „konnte nicht nachsehen" ist etwas anderes als „es gibt keine".
# Deshalb None und nicht [] — das UI behält dann seinen alten Stand.
zustand["devices"] = None
# ⚠️ Der Server-Zustand gehört MIT in den Schnappschuss (Befund 28.08.2026).
#
# Er fehlte, und der Wächter schickt ihn nur alle 15 Sekunden. Ein frisch
# geladenes Dashboard stand deshalb bis zu einer Viertelminute auf
# „Platz für Rippy: unbekannt" — und das sieht aus wie eine Auskunft,
# obwohl es keine ist. Genau das hat der Commander im Windows-Fenster
# gesehen.
#
# Mit Zeitgrenze, aus demselben Grund wie bei den Laufwerken: `disk_usage`
# auf einer toten Netzfreigabe hängt im Kernel.
try:
zustand.update(await asyncio.wait_for(system_stand_fuer_snapshot(),
timeout=5))
except Exception: # noqa: BLE001
# Bewusst breit und bewusst still: Der Schnappschuss ist die Grundlage
# fuer ALLES im UI. Lieber ohne Platzangabe (das UI zeigt dann seinen
# alten Stand) als gar kein Schnappschuss.
pass
return zustand
async def system_stand_fuer_snapshot() -> dict:
"""Platz, Worker und Ablageziele — dieselben Felder wie im Wächter-Takt.
Getrennte Funktion, damit `_snapshot` und der Wächter dasselbe liefern.
Zwei Wege, die dieselbe Kachel füllen, aber verschiedene Felder schicken,
wären eine Anzeige, die je nach Zeitpunkt anders aussieht.
"""
info, ablagen = await asyncio.gather(
system_info(), storage_targets(), return_exceptions=True)
stand = {}
if not isinstance(info, Exception):
stand["info"] = info
if not isinstance(ablagen, Exception):
stand["ablagen"] = ablagen
return stand
def _sse_rahmen(ereignis: dict) -> str:
"""Ein Ereignis im SSE-Format. `id:` ist die Basis der Wiederaufnahme."""
nutzlast = json.dumps(ereignis, ensure_ascii=False)
@@ -1846,6 +1882,45 @@ async def browse_mkdir(request: MkdirRequest):
# ehrlich beantworten.
@app.get("/betrieb")
async def betrieb_auskunft():
"""In welchem Betrieb laeuft Rippy — und was kann dieser Betrieb?
## Warum es diese Route gibt (Commander-Befund 28.08.2026)
Im Windows-Fenster stand auf der Server-Status-Kachel:
Worker erreichbar: 0 von 1
Kein Worker antwortet — Pruefen: docker compose ps
Container-Platte: unbekannt
Freigaben: keine eingehaengt
Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen
Container, kein `docker compose`, keinen zweiten Worker — Rippy rippt
dort selbst.
Die Ursache war nicht die Anzeige, sondern was ihr fehlte: **Das UI hat
nie erfahren, worauf es laeuft.** `config.py` kennt das Profil seit
V2-2, weitergegeben wurde es nie. Also hat das UI angenommen.
Geantwortet wird mit FAEHIGKEITEN, nicht mit einem Modus-Namen — sonst
muesste die Oberflaeche aus einem Namen auf Verhalten schliessen, und
das bricht beim naechsten Betriebsfall. Begruendung vollstaendig in
`src/rippy/betrieb.py`.
"""
from rippy import betrieb as betriebs_auskunft
from rippy import config as rippy_config
try:
werte = rippy_config.laden()
except Exception: # noqa: BLE001
# Eine unlesbare Konfiguration darf die Oberflaeche nicht lahmlegen —
# sie bekommt dann die Vorgaben, und die stimmen fuer den haeufigsten
# Fall. Der Fehler faellt an anderer Stelle laut auf.
werte = {}
return betriebs_auskunft.auskunft(werte)
@app.get("/system/werkzeuge")
async def system_werkzeuge():
"""Was ist installiert, wo, in welcher Fassung — und gibt es Neueres?
@@ -2119,17 +2194,35 @@ async def system_info():
Worker, freier Platz auf Media- und Arbeits-Volume, MakeMKV-Key-Status."""
def sammle():
info = {"api_version": app.version, "plaetze": [], "workers": db.list_workers()}
for name, pfad in (("Media (/app/media)", MEDIA_ROOT),
("Arbeitsverzeichnis (/app/temp)", "/app/temp")):
# ⚠️ NICHT fest /app/media und /app/temp (Befund 28.08.2026).
#
# Beide Pfade gibt es unter Windows nicht. `shutil.disk_usage` warf,
# die Liste blieb leer, und im Windows-Fenster stand "Platz fuer
# Rippy: unbekannt" — obwohl auf dem Laufwerk natuerlich Platz war.
# Eine Nichtauskunft, die wie eine Auskunft aussieht.
from rippy import betrieb as betriebs_auskunft
from rippy import config as rippy_config
try:
werte = rippy_config.laden()
except Exception: # noqa: BLE001
werte = {}
for ort in betriebs_auskunft.platz_orte(werte):
# Frisch installiert gibt es den Ablage-Ordner noch nicht. Dann
# das naechste vorhandene Elternverzeichnis messen: Der Nutzer
# will wissen, ob auf dem LAUFWERK Platz ist.
pfad = betriebs_auskunft.naechster_vorhandener(ort["pfad"])
if not pfad:
continue
try:
nutzung = shutil.disk_usage(pfad)
info["plaetze"].append({
"name": name,
"frei_gb": round(nutzung.free / 1024**3, 1),
"gesamt_gb": round(nutzung.total / 1024**3, 1),
})
except OSError:
pass
continue
info["plaetze"].append({
"name": ort["name"],
"frei_gb": round(nutzung.free / 1024**3, 1),
"gesamt_gb": round(nutzung.total / 1024**3, 1),
})
einstellungen = db.get_settings()
info["makemkv_key_ui"] = bool((einstellungen.get("makemkvAppKey") or "").strip())
info["webhook_gesetzt"] = bool((einstellungen.get("notificationWebhook") or "").strip())
+83 -11
View File
@@ -2,16 +2,20 @@
Warum: Kein anderer Test importiert main.py — ein Tippfehler dort fiele sonst
erst beim Container-Start auf (und die Ampel bliebe fälschlich grün).
Läuft nur unter Linux (detection.py nutzt fcntl/ioctl), also genau dort,
wo auch die Ampel läuft.
## Warum das hier nicht mehr uebersprungen wird (28.08.2026)
Bis V2-4 stand hier ein `pytest.skip` fuer Windows: `main.py` importierte
`rippy.drives.linux` direkt, und das braucht `fcntl`. Seit die Treiberwahl
ueber den Port `rippy.ports.Drives` laeuft, laedt `main.py` auf BEIDEN
Plattformen — nachgemessen: 57 Routen, sauberer Import.
Das Ueberspringen war damit nicht mehr Vorsicht, sondern eine Luecke: Diese
siebzehn Tests liefen nur auf der Ampel. Und ein Test, der nur auf einer
Plattform greift, ist eine halbe Zusage — genau daran ist die Ampel am
28.08.2026 fuenf Laeufe lang unbemerkt rot gewesen.
"""
import sys
import pytest
if sys.platform == "win32": # pragma: no cover
pytest.skip("detection.py braucht fcntl (Linux)", allow_module_level=True)
import pytest # noqa: F401 (einzelne Tests brauchen ihn fuer skipif)
def test_main_importierbar_und_routen_verdrahtet():
@@ -27,6 +31,9 @@ def test_main_importierbar_und_routen_verdrahtet():
# Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
# unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
"/system/keystore",
# Ohne die weiss das UI nicht, worauf es laeuft — und zeigt dann auf
# einem Windows-PC "Pruefen: docker compose ps" (Befund 28.08.2026).
"/betrieb",
):
assert pfad in routen, f"Route {pfad} fehlt"
@@ -317,9 +324,18 @@ def test_snapshot_meldet_unlesbare_laufwerke_als_none(monkeypatch):
assert zustand["jobs"] == []
def test_snapshot_liefert_alle_vier_bereiche(monkeypatch):
def test_snapshot_liefert_das_ganze_bild(monkeypatch):
"""Ein Client, der sich verbindet, bekommt das GANZE Bild — sonst muesste
er den Rest raten und faellt auf Polling zurueck."""
er den Rest raten und faellt auf Polling zurueck.
## Warum der Server-Zustand dazugehoert (Befund 28.08.2026)
Er fehlte im Schnappschuss, und der Waechter schickt ihn nur alle 15
Sekunden. Ein frisch geladenes Dashboard stand deshalb bis zu einer
Viertelminute auf „Platz fuer Rippy: unbekannt" — und das sieht aus wie
eine Auskunft, obwohl es keine ist. Genau das hat der Commander im
Windows-Fenster gesehen.
"""
import asyncio
import main
@@ -328,9 +344,65 @@ def test_snapshot_liefert_alle_vier_bereiche(monkeypatch):
monkeypatch.setattr(main.db, "list_workers", lambda: [{"name": "pc"}])
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
monkeypatch.setattr(main.db, "get_settings", lambda: {})
zustand = asyncio.run(main._snapshot())
assert set(zustand) == {"jobs", "workers", "logs", "devices"}
assert {"jobs", "workers", "logs", "devices"} <= set(zustand)
assert "info" in zustand, (
"Ohne den Server-Zustand steht das Dashboard nach jedem Neuladen "
"bis zu 15 Sekunden auf 'unbekannt'."
)
assert zustand["devices"] == [] # wirklich leer, nicht „unbekannt"
assert zustand["workers"] == [{"name": "pc"}]
def test_ein_kaputter_server_zustand_kippt_den_schnappschuss_nicht(monkeypatch):
"""Der Schnappschuss ist die Grundlage fuer ALLES im UI. Lieber ohne
Platzangabe (das UI behaelt seinen Stand) als gar kein Schnappschuss."""
import asyncio
import main
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
monkeypatch.setattr(main.db, "list_workers", lambda: [])
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
async def platzt():
raise RuntimeError("Platte weg")
monkeypatch.setattr(main, "system_stand_fuer_snapshot", platzt)
zustand = asyncio.run(main._snapshot())
assert {"jobs", "workers", "logs", "devices"} <= set(zustand)
def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
"""Der Befund vom 28.08.2026: Das Windows-Fenster zeigte "Worker
erreichbar: 0 von 1", "Container-Platte" und "Pruefen: docker compose ps"
— auf einem PC ohne Container und ohne zweiten Worker.
Das UI muss FAEHIGKEITEN bekommen, keinen Modus-Namen: Aus einem Namen
auf Verhalten zu schliessen bricht beim naechsten Betriebsfall (ein
Docker-All-in-One hat Container-Pfade, aber keine externen Worker).
"""
from fastapi.testclient import TestClient
from main import app
# OHNE `with`: Der Kontextmanager loest den Lebenszyklus aus, und der legt
# Tabellen an — auf der Ampel gibt es keine Datenbank. Genau daran ist
# Lauf 170 rot geworden. /betrieb braucht keine.
antwort = TestClient(app).get("/betrieb")
assert antwort.status_code == 200
daten = antwort.json()
assert daten["modus"] in ("standalone", "verteilt")
assert daten["plattform"] in ("windows", "linux", "macos")
assert set(daten["kann"]) == {"externe_worker", "freigaben_einhaengen",
"container_pfade", "werkzeuge_verwalten"}
assert all(isinstance(w, bool) for w in daten["kann"].values())
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
if not daten["im_container"]:
assert daten["hilfe_befehl"] == ""