feat: Worker V2 Flet GUI mit Feature-Parität und neuem Installer
This commit is contained in:
@@ -1,274 +0,0 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user