150 lines
5.5 KiB
Python
150 lines
5.5 KiB
Python
"""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")
|
|
|
|
|