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
+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"] == ""