"""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 sys import urllib.request import webbrowser 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") def fenster_bauen(): # pragma: no cover — reine Oberfläche import tkinter as tk from tkinter import scrolledtext wurzel = tk.Tk() wurzel.title(f"Rippy Worker — {WORKER_NAME}") wurzel.geometry("760x620") wurzel.configure(bg=BG) kopf = tk.Frame(wurzel, bg=BG) kopf.pack(fill="x", padx=16, pady=(14, 6)) tk.Label(kopf, text=f"Rippy Worker „{WORKER_NAME}“", bg=BG, fg=TEXT, font=("Segoe UI", 14, "bold")).pack(anchor="w") tk.Label(kopf, text=f"Rippy: {RIPPY_HOST or 'keine Adresse gesetzt'} · " f"{SLOTS} Auftrag/Aufträge gleichzeitig", bg=BG, fg=GEDAEMPFT, font=("Segoe UI", 9)).pack(anchor="w") status = tk.Label(wurzel, text="wird geladen …", bg=BG, fg=GEDAEMPFT, font=("Segoe UI", 10, "bold"), anchor="w", justify="left", wraplength=720) status.pack(fill="x", padx=16, pady=(8, 2)) maschine = tk.Label(wurzel, text="", bg=BG, fg=GEDAEMPFT, font=("Consolas", 9), anchor="w", justify="left") maschine.pack(fill="x", padx=16) tk.Label(wurzel, text="Aufgaben", bg=BG, fg=TEXT, font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=16, pady=(12, 2)) aufgaben = tk.Listbox(wurzel, height=6, bg=PANEL, fg=TEXT, font=("Consolas", 9), borderwidth=0, highlightthickness=1, highlightbackground="#1e293b", selectbackground="#1e293b") aufgaben.pack(fill="x", padx=16) tk.Label(wurzel, text="Log dieser Maschine", bg=BG, fg=TEXT, font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=16, pady=(12, 2)) log = scrolledtext.ScrolledText(wurzel, height=12, bg=PANEL, fg=GEDAEMPFT, font=("Consolas", 8), borderwidth=0, highlightthickness=1, highlightbackground="#1e293b", wrap="none") log.pack(fill="both", expand=True, padx=16) knopf_reihe = tk.Frame(wurzel, bg=BG) knopf_reihe.pack(fill="x", padx=16, pady=12) def knopf(text, aktion, farbe=AMBER, vordergrund=BG): b = tk.Button(knopf_reihe, text=text, command=aktion, bg=farbe, fg=vordergrund, relief="flat", font=("Segoe UI", 9, "bold"), padx=12, pady=6, cursor="hand2") b.pack(side="left", padx=(0, 8)) return b knopf("Rippy öffnen", lambda: RIPPY_HOST and webbrowser.open(f"http://{RIPPY_HOST}")) knopf("Log in Rippy", lambda: RIPPY_HOST and webbrowser.open(f"http://{RIPPY_HOST}/logs"), farbe="#1e293b", vordergrund=TEXT) def deinstallieren_fragen(): from tkinter import messagebox if messagebox.askyesno( "Rippy Worker deinstallieren", f"Den Worker „{WORKER_NAME}“ von diesem PC entfernen?\n\n" "Der Ordner wird gelöscht, der Autostart entfernt und der Eintrag in " "Rippy abgemeldet. Gerippte Dateien bleiben unberührt.", ): deinstallieren() wurzel.destroy() knopf("Deinstallieren", deinstallieren_fragen, farbe=ROSE, vordergrund="white") log_pfad = _log_pfad() def auffrischen(): caps = hole("/capabilities") jobs = hole("/jobs") or [] zustand = worker_zustand(caps, WORKER_NAME) laufend = next((j for j in jobs if j.get("status") == "transcoding"), None) text, farbe = status_text(zustand, laufend) status.configure(text=text, fg=farbe) info = zustand.get("info") or {} teile = [t for t in ( info.get("cpu_modell"), f"{info.get('cpu_kerne')} Kerne" if info.get("cpu_kerne") else "", info.get("cpu_simd") if info.get("cpu_simd") not in ("", "unbekannt") else "", ", ".join(zustand.get("encoders") or []), ) if t] maschine.configure(text=" · ".join(teile)) aufgaben.delete(0, "end") if not jobs: aufgaben.insert("end", " keine Aufgaben in Rippy") for job in jobs[:8]: aufgaben.insert("end", " " + job_text(job)) neu = "\n".join(letzte_zeilen(log_pfad)) if neu != log.get("1.0", "end").strip(): am_ende = log.yview()[1] >= 0.999 log.delete("1.0", "end") log.insert("1.0", neu or "(noch keine Zeilen — das Log füllt sich, " "sobald der Worker etwas tut)") if am_ende: log.see("end") wurzel.after(TAKT_MS, auffrischen) auffrischen() return wurzel if __name__ == "__main__": # pragma: no cover try: fenster_bauen().mainloop() except Exception as e: # Ohne Konsole (pythonw) wäre ein Traceback unsichtbar — also sagen, was # los ist, statt still zu verschwinden. try: import tkinter.messagebox as mb mb.showerror("Rippy Worker", f"Verwaltung konnte nicht öffnen:\n{e}") except Exception: print(f"Verwaltung konnte nicht öffnen: {e}", file=sys.stderr)