"""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")