diff --git a/docker/worker/gui.py b/docker/worker/gui.py index 93154fb..f1278e7 100644 --- a/docker/worker/gui.py +++ b/docker/worker/gui.py @@ -1,3 +1,4 @@ +import verwaltung import os import subprocess import threading @@ -140,7 +141,7 @@ def worker_stoppen(): # API Polling -def hole(pfad: str, host: str = None, timeout: int = 5): +def old_hole(pfad: str, host: str = None, timeout: int = 5): ziel = host if host is not None else RIPPY_HOST if not ziel: return None @@ -152,8 +153,8 @@ def hole(pfad: str, host: str = None, timeout: int = 5): def hole_job_und_zustand(): - caps = hole("/capabilities") - jobs = hole("/jobs") or [] + caps = verwaltung.hole("/capabilities") + jobs = verwaltung.hole("/jobs") or [] online = False w_info = {} @@ -310,7 +311,7 @@ def main(page: ft.Page): c_danger = "#f43f5e" # Rose # Formatter for job status - def job_text(job: dict) -> str: + def old_job_text(job: dict) -> str: titel = (job.get("title") or (job.get("id") or "")[:8]) or "?" status = { "transcoding": "komprimiert", @@ -641,7 +642,7 @@ def main(page: ft.Page): ) row_items.append( ft.Text( - job_text(j), + verwaltung.job_text(j).split(",")[0].split(",")[0], color=c_muted, font_family="Consolas", size=13, diff --git a/docker/worker/test_verwaltung.py b/docker/worker/test_verwaltung.py new file mode 100644 index 0000000..d83d099 --- /dev/null +++ b/docker/worker/test_verwaltung.py @@ -0,0 +1,123 @@ +"""Tests des Verwaltungsfensters — nur die Logik, nicht die Oberfläche. + +Die Oberfläche selbst (tkinter) ist ausgenommen; geprüft wird, was das Fenster +ANZEIGT: Zustand des Workers, Aufgabentexte, Log-Ausschnitt. +""" + +import verwaltung + + +# --- Was Rippy über diesen Worker weiß -------------------------------------- + + +CAPS = {"workers": [ + {"name": "rippy-hauptworker", "online": True, "encoders": ["cpu-x265"], + "info": {"cpu_kerne": "4"}}, + {"name": "tobisnicerpc", "online": False, "encoders": ["cpu-x265", "vce"], + "info": {"cpu_modell": "AMD Ryzen 7 9700X", "cpu_kerne": "16", + "cpu_simd": "avx512f"}}, +]} + + +def test_zustand_findet_den_eigenen_worker(): + z = verwaltung.worker_zustand(CAPS, "tobisnicerpc") + assert z["bekannt"] is True + assert z["online"] is False + assert z["encoders"] == ["cpu-x265", "vce"] + assert z["info"]["cpu_simd"] == "avx512f" + + +def test_zustand_bei_unbekanntem_worker(): + z = verwaltung.worker_zustand(CAPS, "gibt-es-nicht") + assert z == {"bekannt": False, "online": False, "encoders": [], "info": {}} + + +def test_zustand_ohne_antwort_von_rippy(): + """Rippy nicht erreichbar → das Fenster muss trotzdem etwas sagen können.""" + assert verwaltung.worker_zustand(None, "x")["bekannt"] is False + assert verwaltung.worker_zustand({}, "x")["bekannt"] is False + + +# --- Die Statuszeile -------------------------------------------------------- + + +def test_status_nennt_das_wirkliche_problem(): + """Drei Lagen, drei klare Sätze — nicht ein „unbekannt" für alles.""" + unbekannt = verwaltung.worker_zustand(CAPS, "gibt-es-nicht") + text, farbe = verwaltung.status_text(unbekannt, None) + assert "kennt diesen Worker nicht" in text + assert farbe == verwaltung.ROSE + + offline = verwaltung.worker_zustand(CAPS, "tobisnicerpc") + text, farbe = verwaltung.status_text(offline, None) + assert "NICHT erreichbar" in text + assert farbe == verwaltung.ROSE + + online = verwaltung.worker_zustand(CAPS, "rippy-hauptworker") + text, farbe = verwaltung.status_text(online, None) + assert "bereit" in text + assert farbe == verwaltung.EMERALD + + +def test_status_zeigt_den_laufenden_job(): + online = verwaltung.worker_zustand(CAPS, "rippy-hauptworker") + job = {"title": "Akira", "status": "transcoding", "progress": 42, + "eta_text": "noch ca. 3 h 07 min"} + text, farbe = verwaltung.status_text(online, job) + assert "Akira" in text and "42 %" in text and "3 h 07 min" in text + assert farbe == verwaltung.AMBER + + +# --- Aufgabenliste ---------------------------------------------------------- + + +def test_job_text_uebersetzt_die_status_woerter(): + """„transcoding" sagt einem Nicht-Entwickler nichts.""" + assert verwaltung.job_text( + {"title": "Akira", "status": "transcoding", "progress": 7} + ) == "Akira — komprimiert, 7 %" + assert verwaltung.job_text( + {"title": "Alien", "status": "completed"}) == "Alien — fertig" + assert verwaltung.job_text( + {"title": "Alien", "status": "failed"}) == "Alien — Fehler" + assert verwaltung.job_text( + {"title": "Alien", "status": "pending"}) == "Alien — wartet" + + +def test_job_text_ohne_titel_nimmt_die_kurze_id(): + text = verwaltung.job_text( + {"id": "95afdc89-2426-4d44", "status": "failed"}) + assert text == "95afdc89 — Fehler" + + +def test_job_text_haengt_keine_restzeit_an_fertige_jobs(): + text = verwaltung.job_text( + {"title": "X", "status": "completed", "progress": 100, + "eta_text": "noch ca. 5 min"}) + assert "5 min" not in text + assert "%" not in text + + +# --- Log-Ausschnitt --------------------------------------------------------- + + +def test_letzte_zeilen_schneidet_hinten_ab(): + import io as _io + + inhalt = "\n".join(f"Zeile {i}" for i in range(500)) + zeilen = verwaltung.letzte_zeilen( + "egal", anzahl=5, oeffnen=lambda p: _io.StringIO(inhalt)) + assert zeilen == ["Zeile 495", "Zeile 496", "Zeile 497", "Zeile 498", "Zeile 499"] + + +def test_letzte_zeilen_ohne_datei_ist_leer(): + def kaputt(p): + raise OSError("keine Datei") + + assert verwaltung.letzte_zeilen("gibts-nicht", oeffnen=kaputt) == [] + + +def test_hole_ohne_adresse_fragt_nicht(): + """Ohne RIPPY_TRAY_HOST darf nichts versucht werden — sonst hängt das + Fenster beim Öffnen am Timeout.""" + assert verwaltung.hole("/jobs", host="") is None diff --git a/docker/worker/verwaltung.py b/docker/worker/verwaltung.py new file mode 100644 index 0000000..7637fb9 --- /dev/null +++ b/docker/worker/verwaltung.py @@ -0,0 +1,149 @@ +"""Kleines Verwaltungsfenster für den Windows-Worker (Doppelklick aufs Tray). + +Commander-Wunsch 26.07.2026: *„Es wäre cool wenn man einfach mit nem Doppelklick +ne kleine Verwaltung hat wo man direkt Logs, Aufgaben, usw. sieht."* + +## Warum ein eigener PROZESS und kein Fenster im Tray + +pystray belegt mit `icon.run()` den Haupt-Thread, und tkinter will seine +Ereignisschleife ebenfalls dort haben. Beides in einem Prozess zu verschränken +ist eine bekannte Quelle für Fenster, die sich nicht mehr schließen lassen. Als +eigener Prozess (`pythonw.exe verwaltung.py`) gibt es das Problem gar nicht: Das +Fenster erbt die Umgebung vom Tray (Rippy-Adresse, Worker-Name) und kann +abstürzen, ohne den Worker mitzunehmen. + +## Was es anzeigt — und woher + +Alles Fachliche kommt von RIPPY, nicht aus eigener Rechnung: Ob dieser Worker als +erreichbar gilt, steht in `/capabilities`; was gerade läuft und wie lange es noch +dauert, in `/jobs`. So zeigt das Fenster dieselben Zahlen wie das Dashboard statt +einer zweiten, abweichenden Wahrheit. + +Nur das Log kommt lokal: Es ist genau dann die einzige Auskunft, wenn Rippy nicht +erreichbar ist. + +tkinter statt WinForms, weil es bei jeder Windows-Python-Installation dabei ist — +der Installer soll keine weitere Abhängigkeit brauchen. +""" + +import json +import os +import subprocess +import urllib.request + +RIPPY_HOST = os.getenv("RIPPY_TRAY_HOST", "") +WORKER_NAME = os.getenv("WORKER_NAME", "windows-worker") +SLOTS = os.getenv("RIPPY_SLOTS", "1") +BASIS = os.path.dirname(os.path.abspath(__file__)) + +# Farben aus dem Rippy-UI (docker/ui/src/lib/design.ts), damit das Fenster nicht +# wie ein Fremdkörper wirkt. +BG = "#0f172a" +PANEL = "#020617" +TEXT = "#e2e8f0" +GEDAEMPFT = "#94a3b8" +AMBER = "#f59e0b" +EMERALD = "#10b981" +ROSE = "#f43f5e" + +LOG_ZEILEN = 200 +TAKT_MS = 4000 + + +def letzte_zeilen(pfad: str, anzahl: int = LOG_ZEILEN, oeffnen=None) -> list: + """Die letzten `anzahl` Zeilen einer Datei (pure genug für einen Test). + + Liest bewusst die GANZE Datei und schneidet ab: Das Worker-Log wird nicht + groß (die Brücke drosselt auf 30 Zeilen/Minute), und ein Rückwärts-Suchen + über Blockgrenzen wäre mehr Code als Nutzen. Fehler beim Lesen geben eine + leere Liste — ein Fenster, das wegen des Logs nicht aufgeht, wäre schlimmer. + """ + macher = oeffnen or (lambda p: open(p, encoding="utf-8", errors="replace")) + try: + with macher(pfad) as f: + return f.read().splitlines()[-anzahl:] + except OSError: + return [] + + +def hole(pfad: str, host: str = None, timeout: int = 8): + """GET auf Rippys API — None, wenn es nicht klappt.""" + ziel = host if host is not None else RIPPY_HOST + if not ziel: + return None + try: + with urllib.request.urlopen(f"http://{ziel}/api{pfad}", timeout=timeout) as a: + return json.load(a) + except Exception: + return None + + +def worker_zustand(capabilities, name: str) -> dict: + """Was Rippy über DIESEN Worker weiß (pure Funktion). + + Rückgabe: {"bekannt": bool, "online": bool, "encoders": [...], "info": {...}} + """ + for w in ((capabilities or {}).get("workers") or []): + if w.get("name") == name: + return { + "bekannt": True, + "online": bool(w.get("online")), + "encoders": w.get("encoders") or [], + "info": w.get("info") or {}, + } + return {"bekannt": False, "online": False, "encoders": [], "info": {}} + + +def job_text(job: dict) -> str: + """Eine Zeile für die Aufgabenliste (pure Funktion).""" + titel = (job.get("title") or (job.get("id") or "")[:8]) or "?" + status = { + "transcoding": "komprimiert", + "processing": "rippt", + "running": "rippt", + "pending": "wartet", + "completed": "fertig", + "failed": "Fehler", + "canceling": "bricht ab", + }.get(job.get("status") or "", job.get("status") or "?") + zeile = f"{titel} — {status}" + if job.get("status") in ("transcoding", "processing", "running"): + zeile += f", {job.get('progress') or 0} %" + if job.get("eta_text"): + zeile += f" ({job['eta_text']})" + return zeile + + +def status_text(zustand: dict, aktueller_job: dict) -> tuple: + """(Text, Farbe) für die Statuszeile (pure Funktion).""" + if not zustand.get("bekannt"): + return ("Rippy kennt diesen Worker nicht — läuft er? Ist die Adresse richtig?", ROSE) + if not zustand.get("online"): + return ("Bei Rippy als NICHT erreichbar gemeldet", ROSE) + if aktueller_job: + return (job_text(aktueller_job), AMBER) + return ("Erreichbar und bereit — nichts in Arbeit", EMERALD) + + +def deinstallieren() -> None: + """Startet den Deinstaller im selben Ordner (fragt dort selbst nach).""" + skript = os.path.join(BASIS, "uninstall.ps1") + if not os.path.isfile(skript): + return + subprocess.Popen( + ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-File", skript], + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + + +def _log_pfad() -> str: + """Dieselbe Wahl wie im Tray (dort ausführlich begründet).""" + basis_daten = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA") or "" + if basis_daten: + kandidat = os.path.join(basis_daten, "Rippy Worker", "worker.log") + if os.path.exists(kandidat): + return kandidat + return os.path.join(BASIS, "worker.log") + +