fix: Deinstaller, Verwaltungsfenster, Dashboard-Widerspruch, Mount-Tempo
Ampel / ampel (push) Successful in 30s
Ampel / ampel (push) Successful in 30s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
$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
|
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 = @"
|
$uninstall = @"
|
||||||
|
# Rippy-Worker DEINSTALLIEREN — rückstandsfrei (MUSS-Kriterium).
|
||||||
|
# Aufruf ohne Parameter fragt nach; -Force fragt nicht (so ruft das Tray auf).
|
||||||
param([switch]`$Force)
|
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
|
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
|
if (-not `$Force) {
|
||||||
# Autostart-Verknüpfung entfernen - BEIDE möglichen Orte, weil die
|
`$antwort = [System.Windows.Forms.MessageBox]::Show(
|
||||||
# Installation je nach Zielverzeichnis persönlich oder maschinenweit war.
|
"Den Rippy-Worker '$wName' von diesem PC entfernen?",
|
||||||
foreach (`$ordner in @("Startup", "CommonStartup")) {
|
"Rippy Worker deinstallieren",
|
||||||
try { Remove-Item -Force (Join-Path ([Environment]::GetFolderPath(`$ordner)) "RippyWorker.lnk") -ErrorAction Stop } catch {}
|
[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.
|
# ... und die geplante Aufgabe aus älteren Installationen, falls vorhanden.
|
||||||
schtasks /delete /tn "RippyWorker" /f 2>`$null | Out-Null
|
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)
|
# 3. In Rippy abmelden, damit dort keine Worker-Leiche stehen bleibt.
|
||||||
Remove-Item -Recurse -Force `$PSScriptRoot
|
try {
|
||||||
[System.Windows.Forms.MessageBox]::Show("Rippy-Worker deinstalliert.","Rippy")
|
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.
|
# 7. Autostart über den Autostart-ORDNER, nicht über schtasks.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -226,11 +226,19 @@ try {
|
|||||||
Invoke-RestMethod -Method Delete "http://$RippyHost/api/workers/$WorkerName" -TimeoutSec 5 | Out-Null
|
Invoke-RestMethod -Method Delete "http://$RippyHost/api/workers/$WorkerName" -TimeoutSec 5 | Out-Null
|
||||||
Write-Host "Worker-Eintrag in Rippy entfernt."
|
Write-Host "Worker-Eintrag in Rippy entfernt."
|
||||||
} catch { Write-Host "Hinweis: Eintrag in Rippy ggf. von Hand löschen (Einstellungen -> Worker)." }
|
} catch { Write-Host "Hinweis: Eintrag in Rippy ggf. von Hand löschen (Einstellungen -> Worker)." }
|
||||||
Set-Location (Split-Path `$PSScriptRoot -Parent)
|
# Ordner abräumen — NICHT von hier aus: Dieses Skript liegt darin, und die
|
||||||
Remove-Item -Recurse -Force `$PSScriptRoot
|
# venv-DLLs sind noch geladen. `Remove-Item -Recurse -Force` auf den eigenen
|
||||||
Write-Host "Rippy-Worker deinstalliert." -ForegroundColor Green
|
# 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."
|
Write-Host "uninstall.ps1 erzeugt."
|
||||||
|
|
||||||
# 9. Optional: Autostart bei Anmeldung (mit Tray)
|
# 9. Optional: Autostart bei Anmeldung (mit Tray)
|
||||||
|
|||||||
+9
-6
@@ -401,9 +401,9 @@ ROHDATEN_INTERVALL_SEKUNDEN = 30
|
|||||||
# Das strukturell zu lösen (Mount auf dem HOST statt im Container) wäre ein
|
# 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".
|
# eigener Umbau und widerspräche „Speicherziele über das UI einhängen".
|
||||||
MOUNT_WACHE_INTERVALL_SEKUNDEN = 60
|
MOUNT_WACHE_INTERVALL_SEKUNDEN = 60
|
||||||
# Erste Prüfung schon nach zehn Sekunden: Genau dann ist die Lage nach einem
|
# Erste Prüfung fast sofort: Genau nach einem Deploy ist die Lage kaputt, und
|
||||||
# Deploy kaputt, und ein Fenster von einer Minute wäre unnötig lang.
|
# jede Sekunde Wartezeit ist eine Sekunde, in der Rippy sein Ziel nicht sieht.
|
||||||
MOUNT_WACHE_ERSTE_PRUEFUNG_SEKUNDEN = 10
|
MOUNT_WACHE_ERSTE_PRUEFUNG_SEKUNDEN = 3
|
||||||
_MOUNT_STAND = {}
|
_MOUNT_STAND = {}
|
||||||
|
|
||||||
|
|
||||||
@@ -430,9 +430,12 @@ def _mounts_nachsehen() -> None:
|
|||||||
return
|
return
|
||||||
for eintrag in eintraege:
|
for eintrag in eintraege:
|
||||||
name = eintrag["name"]
|
name = eintrag["name"]
|
||||||
# Zweimal mit Abstand: Nach einem frischen Mount antwortet die Freigabe
|
# Zum ERKENNEN genügt die einfache, schnelle Probe: Ein toter Mount
|
||||||
# einen Moment und stirbt dann wieder (Wettlauf mit dem lazy umount).
|
# antwortet gar nicht, nicht nur manchmal. Die Doppelprobe steckt dort,
|
||||||
erreichbar = mount_verwaltung.wirklich_erreichbar(name)
|
# 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)
|
vorher = _MOUNT_STAND.get(name)
|
||||||
_MOUNT_STAND[name] = erreichbar
|
_MOUNT_STAND[name] = erreichbar
|
||||||
if erreichbar:
|
if erreichbar:
|
||||||
|
|||||||
@@ -127,6 +127,18 @@ def _stale_mounts_loesen(ziel: str) -> int:
|
|||||||
if ergebnis.returncode != 0:
|
if ergebnis.returncode != 0:
|
||||||
break # nichts (mehr) gemountet
|
break # nichts (mehr) gemountet
|
||||||
geloest += 1
|
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
|
return geloest
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
|
||||||
assert mounts.wirklich_erreichbar("rippy", warten=gewartet.append) is False
|
assert mounts.wirklich_erreichbar("rippy", warten=gewartet.append) is False
|
||||||
assert gewartet == [] # nicht drei Sekunden fuer nichts
|
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 == []
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ interface WorkerLive {
|
|||||||
info?: { cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string, extern?: string }
|
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 {
|
function posterUrl(meta?: JobMeta | null): string | null {
|
||||||
const p = meta?.poster_path
|
const p = meta?.poster_path
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
@@ -80,6 +89,7 @@ export default function Dashboard() {
|
|||||||
const [rohdatenMitloeschen, setRohdatenMitloeschen] = useState(false)
|
const [rohdatenMitloeschen, setRohdatenMitloeschen] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
||||||
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
|
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
|
||||||
|
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -135,7 +145,7 @@ export default function Dashboard() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [jobsData, sysData, capsData] = await Promise.all([
|
const [jobsData, sysData, capsData, devData] = await Promise.all([
|
||||||
fetchJobs(),
|
fetchJobs(),
|
||||||
api.get('/system/info').then(r => r.data).catch(() => null),
|
api.get('/system/info').then(r => r.data).catch(() => null),
|
||||||
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
|
// 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
|
// schnell (0,003 s, Ping läuft im Hintergrund) — er darf hier also
|
||||||
// im 4-Sekunden-Takt mitlaufen.
|
// im 4-Sekunden-Takt mitlaufen.
|
||||||
api.get('/capabilities').then(r => r.data?.workers || []).catch(() => []),
|
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)
|
setJobs(jobsData)
|
||||||
if (sysData) setSystemInfo(sysData)
|
if (sysData) setSystemInfo(sysData)
|
||||||
setWorkersLive(capsData)
|
setWorkersLive(capsData)
|
||||||
|
setLaufwerke(devData)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -189,10 +203,23 @@ export default function Dashboard() {
|
|||||||
const workerOnline = workersLive.filter(w => w.online)
|
const workerOnline = workersLive.filter(w => w.online)
|
||||||
const encoderWorker = workerOnline.filter(w => (w.encoders || []).length > 0)
|
const encoderWorker = workerOnline.filter(w => (w.encoders || []).length > 0)
|
||||||
const hardwareWorker = workerOnline.filter(w => (w.encoders || []).some(e => !e.startsWith('cpu')))
|
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'
|
* Phase im Klartext — „processing" heißt rippen, „transcoding" komprimieren.
|
||||||
: aktiverJob.status === 'transcoding' ? 'Kompression läuft (HandBrake)'
|
*
|
||||||
: 'Rip läuft (MakeMKV, verlustfrei)'
|
* 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 => {
|
const filteredJobs = jobs.filter(j => {
|
||||||
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
||||||
@@ -537,7 +564,11 @@ export default function Dashboard() {
|
|||||||
<p className="text-[11px] font-mono text-slate-500">
|
<p className="text-[11px] font-mono text-slate-500">
|
||||||
{queueJobs.length > 0
|
{queueJobs.length > 0
|
||||||
? `${queueJobs.length} Job(s) warten in der Schlange`
|
? `${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'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -303,6 +303,47 @@ def log_datei_anzeigen(icon, item):
|
|||||||
os.startfile(LOG_PFAD) # noqa: S606 — bewusst: Standard-Editor des Nutzers
|
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):
|
def beenden(icon, item):
|
||||||
worker_stoppen()
|
worker_stoppen()
|
||||||
icon.stop()
|
icon.stop()
|
||||||
@@ -312,6 +353,10 @@ menue = pystray.Menu(
|
|||||||
pystray.MenuItem(status_text, None, enabled=False),
|
pystray.MenuItem(status_text, None, enabled=False),
|
||||||
pystray.MenuItem(restzeit_text, None, enabled=False),
|
pystray.MenuItem(restzeit_text, None, enabled=False),
|
||||||
pystray.Menu.SEPARATOR,
|
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(
|
pystray.MenuItem(
|
||||||
lambda item: "Worker stoppen" if worker_laeuft() else "Worker starten",
|
lambda item: "Worker stoppen" if worker_laeuft() else "Worker starten",
|
||||||
umschalten,
|
umschalten,
|
||||||
@@ -320,6 +365,7 @@ menue = pystray.Menu(
|
|||||||
pystray.MenuItem("Log in Rippy anzeigen", log_in_rippy),
|
pystray.MenuItem("Log in Rippy anzeigen", log_in_rippy),
|
||||||
pystray.MenuItem("Log-Datei auf diesem PC", log_datei_anzeigen),
|
pystray.MenuItem("Log-Datei auf diesem PC", log_datei_anzeigen),
|
||||||
pystray.Menu.SEPARATOR,
|
pystray.Menu.SEPARATOR,
|
||||||
|
pystray.MenuItem("Worker deinstallieren …", deinstallieren),
|
||||||
pystray.MenuItem("Beenden (stoppt den Worker)", beenden),
|
pystray.MenuItem("Beenden (stoppt den Worker)", beenden),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user