From 2a90538473d9f655b825da222e075188c67100b9 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 26 Jul 2026 14:52:53 +0200 Subject: [PATCH] fix: Deinstaller, Verwaltungsfenster, Dashboard-Widerspruch, Mount-Tempo Vier Meldungen des Commanders, alle nachgemessen. 1. DEINSTALLER LIEF NICHT MEHR - reproduziert mit echtem PowerShell: Der Typ [System.Windows.Forms.MessageBox] wurde nicht gefunden. `Add-Type -AssemblyName System.Windows.Forms` stand EINE ZEILE ZU SPAET, die MessageBox wurde davor benutzt. Der Deinstaller starb also in seiner ersten Arbeitszeile, jedes Mal. Zwei weitere Maengel gleich mit: Kodierung war ASCII trotz Umlauten, und `Remove-Item -Recurse -Force $PSScriptRoot` loescht den Ordner, in dem das laufende Skript liegt - das klappt auf Windows nicht zuverlaessig (venv-DLLs sind geladen). Jetzt raeumt ein losgeloestes cmd nach, sobald PowerShell weg ist. Der erzeugte Deinstaller ist gegengeprueft: parst, BOM da, Umlaute intakt. 2. VERWALTUNGSFENSTER (Doppelklick aufs Tray). Zeigt Status, Aufgaben und Log, plus Knoepfe fuer Rippy, Log-in-Rippy und Deinstallieren - Deinstallieren geht damit auch aus dem Tray-Menue. Eigener PROZESS statt Fenster im Tray, weil pystray und tkinter beide den Haupt-Thread wollen; tkinter statt WinForms, weil es bei jeder Windows-Python-Installation dabei ist. Headless gerendert und angesehen. Alles Fachliche kommt von Rippy (/capabilities, /jobs), damit dort nicht eine zweite, abweichende Wahrheit steht. 3. DASHBOARD-WIDERSPRUCH. Oben stand "Akira im Laufwerk erkannt", die Server-Status-Karte gleichzeitig "Bereit - keine Disc in Arbeit / Disc einlegen". Zwei Aussagen, ein Blick. Die Karte fragt jetzt /devices und sagt "Disc erkannt - wartet auf Rippen starten" samt Titel. 4. MOUNT-TEMPO. Der Commander: "150 Sekunden? Das ist verrueckt langsam." Recht hat er. Gemessen ging die Zeit fast komplett in einen FEHLVERSUCH: `umount -l` ist lazy, der Abbau passiert spaeter, und wer direkt danach mountet, riskiert dass der Abbau hinter dem neuen Mount landet. Der erste Reparaturversuch scheiterte dadurch regelmaessig - und der zweite kostete 30 s mount-Timeout plus Pruefungen. Jetzt werden 1,5 s auf den Abbau gewartet, bevor neu gemountet wird; die Wache prueft erstmals nach 3 s statt 10 s und benutzt zum ERKENNEN die einfache schnelle Probe (die Doppelprobe steckt dort, wo der Wettlauf lauert: direkt nach dem Mount). Co-Authored-By: Claude Opus 5 --- deploy/worker-windows/install-gui.ps1 | 77 ++++++-- deploy/worker-windows/install.ps1 | 16 +- docker/api/main.py | 15 +- docker/api/mounts.py | 12 ++ docker/api/test_mounts_helpers.py | 41 ++++ docker/ui/src/pages/Dashboard.tsx | 43 +++- docker/worker/test_verwaltung.py | 123 ++++++++++++ docker/worker/tray.py | 46 +++++ docker/worker/verwaltung.py | 274 ++++++++++++++++++++++++++ 9 files changed, 619 insertions(+), 28 deletions(-) create mode 100644 docker/worker/test_verwaltung.py create mode 100644 docker/worker/verwaltung.py diff --git a/deploy/worker-windows/install-gui.ps1 b/deploy/worker-windows/install-gui.ps1 index eaa41a3..588719b 100644 --- a/deploy/worker-windows/install-gui.ps1 +++ b/deploy/worker-windows/install-gui.ps1 @@ -477,25 +477,78 @@ function Do-Install { $workBat = "@echo off`r`ncd /d `"%~dp0`"`r`nset REDIS_URL=redis://${rHost}:6379/0`r`nset DATABASE_URL=postgresql://rippy:rippy@${rHost}:5432/rippy`r`nset API_URL=http://${rHost}:8000`r`nset WORKER_NAME=$wName`r`nset RIPPY_SLOTS=$slots`r`n${mapZeile}set PATH=%~dp0;%PATH%`r`nvenv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode $poolArg -n ${wName}@%%h" Set-Content -Path "start-worker.bat" -Value $workBat -Encoding ASCII + # --- Deinstaller --------------------------------------------------- + # + # ⚠️ REPARIERT 26.07.2026. Der Commander meldete: „Der Uninstaller vom + # Worker funktioniert nicht mehr." Reproduziert mit echtem PowerShell: + # + # Der Typ [System.Windows.Forms.MessageBox] wurde nicht gefunden. + # + # Ursache: `Add-Type -AssemblyName System.Windows.Forms` stand EINE ZEILE + # ZU SPÄT — die MessageBox wurde davor benutzt. Der Deinstaller starb also + # in seiner ersten Arbeitszeile, jedes Mal. + # + # Zwei weitere Mängel gleich mit erledigt: + # - Kodierung war ASCII, obwohl Umlaute drin stehen (Kauderwelsch). + # - `Remove-Item -Recurse -Force $PSScriptRoot` löscht den Ordner, in dem + # das laufende Skript selbst liegt. Das klappt auf Windows nicht + # zuverlässig (die venv-DLLs sind noch geladen). Jetzt räumt ein + # losgelöstes cmd den Ordner ab, nachdem PowerShell beendet ist. $uninstall = @" +# Rippy-Worker DEINSTALLIEREN — rückstandsfrei (MUSS-Kriterium). +# Aufruf ohne Parameter fragt nach; -Force fragt nicht (so ruft das Tray auf). param([switch]`$Force) -if (-not `$Force) { if ([System.Windows.Forms.MessageBox]::Show("Rippy-Worker '$wName' deinstallieren?","Rippy",4) -ne "Yes") { exit } } + +# ZUERST die Assembly laden, DANN die MessageBox benutzen. Genau daran ist der +# alte Deinstaller gescheitert. Add-Type -AssemblyName System.Windows.Forms -Get-CimInstance Win32_Process | Where-Object { `$_.ExecutablePath -like "`$PSScriptRoot*" } | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue } -Start-Sleep 2 -# Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die -# Installation je nach Zielverzeichnis persönlich oder maschinenweit war. -foreach (`$ordner in @("Startup", "CommonStartup")) { - try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {} + +if (-not `$Force) { + `$antwort = [System.Windows.Forms.MessageBox]::Show( + "Den Rippy-Worker '$wName' von diesem PC entfernen?", + "Rippy Worker deinstallieren", + [System.Windows.Forms.MessageBoxButtons]::YesNo, + [System.Windows.Forms.MessageBoxIcon]::Warning) + if (`$antwort -ne [System.Windows.Forms.DialogResult]::Yes) { exit } +} + +`$ordner = `$PSScriptRoot + +# 1. Alles beenden, was aus diesem Ordner läuft (Tray, celery, HandBrake). +Get-CimInstance Win32_Process | Where-Object { + `$_.ExecutablePath -like "`$ordner*" +} | ForEach-Object { Stop-Process -Id `$_.ProcessId -Force -ErrorAction SilentlyContinue } +Start-Sleep 3 + +# 2. Autostart entfernen — BEIDE möglichen Orte, weil die Installation je nach +# Zielverzeichnis persönlich oder maschinenweit war. +foreach (`$ort in @("Startup", "CommonStartup")) { + `$lnk = Join-Path ([Environment]::GetFolderPath(`$ort)) "RippyWorker.lnk" + if (Test-Path `$lnk) { Remove-Item -Force `$lnk -ErrorAction SilentlyContinue } } # ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden. schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null -try { Invoke-RestMethod -Method Delete "http://$rHost/api/workers/$wName" -TimeoutSec 5 | Out-Null } catch {} -Set-Location (Split-Path `$PSScriptRoot -Parent) -Remove-Item -Recurse -Force `$PSScriptRoot -[System.Windows.Forms.MessageBox]::Show("Rippy-Worker deinstalliert.","Rippy") + +# 3. In Rippy abmelden, damit dort keine Worker-Leiche stehen bleibt. +try { + Invoke-RestMethod -Method Delete "http://$rHost/api/workers/$wName" -TimeoutSec 5 | Out-Null +} catch { } + +# 4. Ordner abräumen. NICHT von hier aus: Dieses Skript liegt darin, und die +# venv-DLLs sind noch geladen. Ein losgelöstes cmd wartet, bis PowerShell +# weg ist, und räumt dann auf. +Set-Location `$env:TEMP +Start-Process cmd.exe -ArgumentList '/c timeout /t 4 /nobreak >nul & rd /s /q "'"`$ordner"'"' -WindowStyle Hidden + +[System.Windows.Forms.MessageBox]::Show( + "Rippy-Worker entfernt. Der Ordner verschwindet in wenigen Sekunden.", + "Rippy Worker") | Out-Null "@ - Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII + # UTF-8 MIT BOM, nicht ASCII: Die Datei enthält Umlaute, und PowerShell + # 5.1 liest .ps1 ohne BOM als ANSI (dokumentiert im Kopf dieser Datei). + [System.IO.File]::WriteAllText( + (Join-Path $InstallDir "uninstall.ps1"), $uninstall, + (New-Object System.Text.UTF8Encoding $true)) # 7. Autostart über den Autostart-ORDNER, nicht über schtasks. # diff --git a/deploy/worker-windows/install.ps1 b/deploy/worker-windows/install.ps1 index d1b02b9..67d5149 100644 --- a/deploy/worker-windows/install.ps1 +++ b/deploy/worker-windows/install.ps1 @@ -226,11 +226,19 @@ try { Invoke-RestMethod -Method Delete "http://$RippyHost/api/workers/$WorkerName" -TimeoutSec 5 | Out-Null Write-Host "Worker-Eintrag in Rippy entfernt." } catch { Write-Host "Hinweis: Eintrag in Rippy ggf. von Hand löschen (Einstellungen -> Worker)." } -Set-Location (Split-Path `$PSScriptRoot -Parent) -Remove-Item -Recurse -Force `$PSScriptRoot -Write-Host "Rippy-Worker deinstalliert." -ForegroundColor Green +# Ordner abräumen — NICHT von hier aus: Dieses Skript liegt darin, und die +# venv-DLLs sind noch geladen. `Remove-Item -Recurse -Force` auf den eigenen +# Ordner klappt auf Windows nicht zuverlässig (Befund 26.07.2026). Ein +# losgelöstes cmd wartet, bis PowerShell weg ist, und räumt dann auf. +Set-Location `$env:TEMP +Start-Process cmd.exe -ArgumentList '/c timeout /t 4 /nobreak >nul & rd /s /q "'"`$PSScriptRoot"'"' -WindowStyle Hidden +Write-Host "Rippy-Worker deinstalliert (Ordner verschwindet in wenigen Sekunden)." -ForegroundColor Green "@ -Set-Content -Path "uninstall.ps1" -Value $uninstall -Encoding ASCII +# UTF-8 MIT BOM statt ASCII: die Datei enthält Umlaute, und PowerShell 5.1 liest +# .ps1 ohne BOM als ANSI. +[System.IO.File]::WriteAllText( + (Join-Path $ziel "uninstall.ps1"), $uninstall, + (New-Object System.Text.UTF8Encoding $true)) Write-Host "uninstall.ps1 erzeugt." # 9. Optional: Autostart bei Anmeldung (mit Tray) diff --git a/docker/api/main.py b/docker/api/main.py index 72f6765..5312ed2 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -401,9 +401,9 @@ ROHDATEN_INTERVALL_SEKUNDEN = 30 # Das strukturell zu lösen (Mount auf dem HOST statt im Container) wäre ein # eigener Umbau und widerspräche „Speicherziele über das UI einhängen". MOUNT_WACHE_INTERVALL_SEKUNDEN = 60 -# Erste Prüfung schon nach zehn Sekunden: Genau dann ist die Lage nach einem -# Deploy kaputt, und ein Fenster von einer Minute wäre unnötig lang. -MOUNT_WACHE_ERSTE_PRUEFUNG_SEKUNDEN = 10 +# Erste Prüfung fast sofort: Genau nach einem Deploy ist die Lage kaputt, und +# jede Sekunde Wartezeit ist eine Sekunde, in der Rippy sein Ziel nicht sieht. +MOUNT_WACHE_ERSTE_PRUEFUNG_SEKUNDEN = 3 _MOUNT_STAND = {} @@ -430,9 +430,12 @@ def _mounts_nachsehen() -> None: return for eintrag in eintraege: name = eintrag["name"] - # Zweimal mit Abstand: Nach einem frischen Mount antwortet die Freigabe - # einen Moment und stirbt dann wieder (Wettlauf mit dem lazy umount). - erreichbar = mount_verwaltung.wirklich_erreichbar(name) + # Zum ERKENNEN genügt die einfache, schnelle Probe: Ein toter Mount + # antwortet gar nicht, nicht nur manchmal. Die Doppelprobe steckt dort, + # wo sie hingehört — in `mounten()`, direkt nach einem frischen Mount, wo + # der Wettlauf mit dem lazy umount lauert. Hier kostete sie nur jede + # Minute drei Sekunden Warten für nichts. + erreichbar = mount_verwaltung.ist_erreichbar(name) vorher = _MOUNT_STAND.get(name) _MOUNT_STAND[name] = erreichbar if erreichbar: diff --git a/docker/api/mounts.py b/docker/api/mounts.py index 92ea8ca..81ab596 100644 --- a/docker/api/mounts.py +++ b/docker/api/mounts.py @@ -127,6 +127,18 @@ def _stale_mounts_loesen(ziel: str) -> int: if ergebnis.returncode != 0: break # nichts (mehr) gemountet geloest += 1 + if geloest: + # ⚠️ Kurz durchatmen, BEVOR neu gemountet wird (Befund 26.07.2026). + # `umount -l` ist lazy: Es hängt sofort aus der Sicht aus, der eigentliche + # Abbau samt Propagation (rshared) passiert später. Wer direkt danach + # mountet, riskiert, dass dieser Abbau HINTER dem neuen Mount landet und + # der Pfad wieder auf die Leiche zeigt. Genau das hat die erste + # Reparatur regelmäßig scheitern lassen — und der zweite Versuch mit + # seinen 30 s Timeout war der Grund, warum eine Wiederanbindung rund + # 150 Sekunden brauchte. Anderthalb Sekunden Warten sparen die. + import time + + time.sleep(1.5) return geloest diff --git a/docker/api/test_mounts_helpers.py b/docker/api/test_mounts_helpers.py index 61ade25..b017cbd 100644 --- a/docker/api/test_mounts_helpers.py +++ b/docker/api/test_mounts_helpers.py @@ -281,3 +281,44 @@ def test_wirklich_erreichbar_spart_das_warten_wenn_schon_die_erste_probe_faellt( monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False) assert mounts.wirklich_erreichbar("rippy", warten=gewartet.append) is False assert gewartet == [] # nicht drei Sekunden fuer nichts + + +def test_stale_loesen_wartet_den_lazy_abbau_ab(monkeypatch): + """Der Grund fuer die 150 Sekunden (Befund 26.07.2026): `umount -l` ist lazy, + der Abbau passiert spaeter. Wer direkt danach mountet, riskiert, dass der + Abbau HINTER dem neuen Mount landet - der erste Reparaturversuch scheiterte + dadurch regelmaessig, und der zweite kostete 30 s Timeout.""" + import time + import types + + import mounts + + gewartet = [] + monkeypatch.setattr(time, "sleep", gewartet.append) + aufrufe = [] + + def fake_run(cmd, **kwargs): + aufrufe.append(cmd) + rc = 0 if len(aufrufe) <= 2 else 1 + return types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"") + + monkeypatch.setattr(mounts.subprocess, "run", fake_run) + assert mounts._stale_mounts_loesen("/app/media/x") == 2 + assert gewartet == [1.5] + + +def test_stale_loesen_wartet_nicht_wenn_nichts_zu_loesen_war(monkeypatch): + """War kein Mount da, gibt es auch keinen Abbau abzuwarten - dann darf die + Reparatur nicht kuenstlich gebremst werden.""" + import time + import types + + import mounts + + gewartet = [] + monkeypatch.setattr(time, "sleep", gewartet.append) + monkeypatch.setattr( + mounts.subprocess, "run", + lambda cmd, **k: types.SimpleNamespace(returncode=1, stdout=b"", stderr=b"")) + assert mounts._stale_mounts_loesen("/app/media/x") == 0 + assert gewartet == [] diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 4976555..b931d5d 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -53,6 +53,15 @@ interface WorkerLive { info?: { cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string, extern?: string } } +// Ein Laufwerk aus GET /devices. Gebraucht, weil die Server-Status-Karte sonst +// „keine Disc in Arbeit" behauptet, während oben auf DEMSELBEN Bildschirm +// „Akira im Laufwerk erkannt" steht (Commander-Befund 26.07.2026). +interface LaufwerkLive { + id: string + status: string + disc?: { title?: string, year?: number | null, disc_type?: string } +} + function posterUrl(meta?: JobMeta | null): string | null { const p = meta?.poster_path if (!p) return null @@ -80,6 +89,7 @@ export default function Dashboard() { const [rohdatenMitloeschen, setRohdatenMitloeschen] = useState(false) const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all') const [workersLive, setWorkersLive] = useState([]) + const [laufwerke, setLaufwerke] = useState([]) const { toast } = useToast() /* @@ -135,7 +145,7 @@ export default function Dashboard() { useEffect(() => { const loadData = async () => { try { - const [jobsData, sysData, capsData] = await Promise.all([ + const [jobsData, sysData, capsData, devData] = await Promise.all([ fetchJobs(), api.get('/system/info').then(r => r.data).catch(() => null), // Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping, @@ -143,10 +153,14 @@ export default function Dashboard() { // schnell (0,003 s, Ping läuft im Hintergrund) — er darf hier also // im 4-Sekunden-Takt mitlaufen. api.get('/capabilities').then(r => r.data?.workers || []).catch(() => []), + // Die Laufwerke: Ohne sie behauptete die Server-Status-Karte + // „keine Disc in Arbeit", während oben die erkannte Disc stand. + api.get('/devices').then(r => r.data || []).catch(() => []), ]) setJobs(jobsData) if (sysData) setSystemInfo(sysData) setWorkersLive(capsData) + setLaufwerke(devData) } finally { setLoading(false) } @@ -189,10 +203,23 @@ export default function Dashboard() { const workerOnline = workersLive.filter(w => w.online) const encoderWorker = workerOnline.filter(w => (w.encoders || []).length > 0) const hardwareWorker = workerOnline.filter(w => (w.encoders || []).some(e => !e.startsWith('cpu'))) - // Phase im Klartext — „processing" heißt rippen, „transcoding" komprimieren. - const phaseText = !aktiverJob ? 'Bereit — keine Disc in Arbeit' - : aktiverJob.status === 'transcoding' ? 'Kompression läuft (HandBrake)' - : 'Rip läuft (MakeMKV, verlustfrei)' + /* + * Phase im Klartext — „processing" heißt rippen, „transcoding" komprimieren. + * + * Die eingelegte Disc gehört mit hinein (Commander-Befund 26.07.2026): Vorher + * stand hier stur „Bereit — keine Disc in Arbeit / Disc einlegen, Rippy erkennt + * sie selbst", während oben auf DEMSELBEN Bildschirm „Akira im Laufwerk + * erkannt" prangte. Zwei Aussagen, ein Blick, Widerspruch — und der Nutzer + * weiß nicht, welcher er glauben soll. + */ + const discImLaufwerk = laufwerke.find(l => l.disc?.title) + const phaseText = aktiverJob + ? (aktiverJob.status === 'transcoding' + ? 'Kompression läuft (HandBrake)' + : 'Rip läuft (MakeMKV, verlustfrei)') + : discImLaufwerk + ? 'Disc erkannt — wartet auf „Rippen starten"' + : 'Bereit — kein Datenträger im Laufwerk' const filteredJobs = jobs.filter(j => { if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding' @@ -537,7 +564,11 @@ export default function Dashboard() {

{queueJobs.length > 0 ? `${queueJobs.length} Job(s) warten in der Schlange` - : 'Disc einlegen — Rippy erkennt sie selbst'} + : discImLaufwerk + ? `${discImLaufwerk.disc!.title}` + + (discImLaufwerk.disc!.year ? ` (${discImLaufwerk.disc!.year})` : '') + + ' liegt bereit — oben auf „Rippen starten"' + : 'Disc einlegen — Rippy erkennt sie selbst'}

)} diff --git a/docker/worker/test_verwaltung.py b/docker/worker/test_verwaltung.py new file mode 100644 index 0000000..d83d099 --- /dev/null +++ b/docker/worker/test_verwaltung.py @@ -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 diff --git a/docker/worker/tray.py b/docker/worker/tray.py index 7c530a5..49db8a3 100644 --- a/docker/worker/tray.py +++ b/docker/worker/tray.py @@ -303,6 +303,47 @@ def log_datei_anzeigen(icon, item): os.startfile(LOG_PFAD) # noqa: S606 — bewusst: Standard-Editor des Nutzers +# Das Verwaltungsfenster läuft als EIGENER Prozess (Begründung in +# verwaltung.py: pystray und tkinter wollen beide den Haupt-Thread). +_verwaltung = {"prozess": None} + + +def verwaltung_oeffnen(icon=None, item=None): + """Kleines Fenster mit Status, Aufgaben und Log (Commander-Wunsch 26.07.2026). + + Ist es schon offen, wird kein zweites gestartet — sonst sammeln sich bei + jedem Doppelklick Fenster an. + """ + laeuft = _verwaltung["prozess"] + if laeuft is not None and laeuft.poll() is None: + return + pythonw = os.path.join(BASIS, "venv", "Scripts", "pythonw.exe") + if not os.path.exists(pythonw): + pythonw = sys.executable + _verwaltung["prozess"] = subprocess.Popen( + [pythonw, os.path.join(BASIS, "verwaltung.py")], + cwd=BASIS, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + + +def deinstallieren(icon, item): + """Deinstallation aus dem Tray heraus (Commander-Wunsch 26.07.2026). + + Das Skript fragt selbst nach (ohne -Force), stoppt alles aus diesem Ordner — + also auch dieses Tray — und räumt danach auf. Deshalb wird hier nichts mehr + erwartet: Der eigene Prozess ist gleich weg. + """ + skript = os.path.join(BASIS, "uninstall.ps1") + if not os.path.exists(skript): + return + subprocess.Popen( + ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-File", skript], + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + + def beenden(icon, item): worker_stoppen() icon.stop() @@ -312,6 +353,10 @@ menue = pystray.Menu( pystray.MenuItem(status_text, None, enabled=False), pystray.MenuItem(restzeit_text, None, enabled=False), pystray.Menu.SEPARATOR, + # `default=True`: Dieser Eintrag wird beim Doppelklick aufs Symbol + # ausgelöst — genau das hat der Commander gewünscht. Er steht zusätzlich + # normal im Menü, damit es auch klappt, wenn der Doppelklick nicht greift. + pystray.MenuItem("Verwaltung öffnen", verwaltung_oeffnen, default=True), pystray.MenuItem( lambda item: "Worker stoppen" if worker_laeuft() else "Worker starten", umschalten, @@ -320,6 +365,7 @@ menue = pystray.Menu( pystray.MenuItem("Log in Rippy anzeigen", log_in_rippy), pystray.MenuItem("Log-Datei auf diesem PC", log_datei_anzeigen), pystray.Menu.SEPARATOR, + pystray.MenuItem("Worker deinstallieren …", deinstallieren), pystray.MenuItem("Beenden (stoppt den Worker)", beenden), ) diff --git a/docker/worker/verwaltung.py b/docker/worker/verwaltung.py new file mode 100644 index 0000000..e0a771b --- /dev/null +++ b/docker/worker/verwaltung.py @@ -0,0 +1,274 @@ +"""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)