fix(worker): restore pure logic module verwaltung.py and its tests (SoC)
Ampel / ampel (push) Failing after 39s
Ampel / ampel (push) Failing after 39s
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import verwaltung
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
@@ -140,7 +141,7 @@ def worker_stoppen():
|
|||||||
|
|
||||||
|
|
||||||
# API Polling
|
# 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
|
ziel = host if host is not None else RIPPY_HOST
|
||||||
if not ziel:
|
if not ziel:
|
||||||
return None
|
return None
|
||||||
@@ -152,8 +153,8 @@ def hole(pfad: str, host: str = None, timeout: int = 5):
|
|||||||
|
|
||||||
|
|
||||||
def hole_job_und_zustand():
|
def hole_job_und_zustand():
|
||||||
caps = hole("/capabilities")
|
caps = verwaltung.hole("/capabilities")
|
||||||
jobs = hole("/jobs") or []
|
jobs = verwaltung.hole("/jobs") or []
|
||||||
|
|
||||||
online = False
|
online = False
|
||||||
w_info = {}
|
w_info = {}
|
||||||
@@ -310,7 +311,7 @@ def main(page: ft.Page):
|
|||||||
c_danger = "#f43f5e" # Rose
|
c_danger = "#f43f5e" # Rose
|
||||||
|
|
||||||
# Formatter for job status
|
# 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 "?"
|
titel = (job.get("title") or (job.get("id") or "")[:8]) or "?"
|
||||||
status = {
|
status = {
|
||||||
"transcoding": "komprimiert",
|
"transcoding": "komprimiert",
|
||||||
@@ -641,7 +642,7 @@ def main(page: ft.Page):
|
|||||||
)
|
)
|
||||||
row_items.append(
|
row_items.append(
|
||||||
ft.Text(
|
ft.Text(
|
||||||
job_text(j),
|
verwaltung.job_text(j).split(",")[0].split(",")[0],
|
||||||
color=c_muted,
|
color=c_muted,
|
||||||
font_family="Consolas",
|
font_family="Consolas",
|
||||||
size=13,
|
size=13,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user