Files
rippy/docker/ui/src/components/WorkerVerwaltung.tsx
T
Hitonabi 67a4a76afa
Ampel / ampel (push) Successful in 28s
fix(ui): Worker-Adressfeld nicht mehr mit window.location vorbelegen
Befund (Commander, Screenshot): Das Feld 'Rippy-Adresse' fuellte sich
automatisch mit window.location.hostname — also der Adresse, ueber die man
das UI geoeffnet hat (bei ihm die eigene PC-IP hinter einem Forward/Proxy,
192.168.178.98). Das ist NICHT zwingend die direkte LAN-IP der
Rippy-Maschine, die der Worker fuer Redis/Postgres (6379/5432) braucht —
der generierte Befehl zeigte damit auf den falschen Host ('Verbindung
verweigert').

Fix:
- Kein Auto-Default mehr (Feld leer); leeres Feld -> sichtbarer
  Platzhalter <RIPPY-IP> im Befehl (erkennbar unvollstaendig statt still
  falsch).
- Label auf 'LAN-IP der Rippy-Maschine (Docker-Host)' geschaerft +
  dauerhaft sichtbarer Hilfetext: die IP der Rippy-Maschine, NICHT dieses
  PCs, und auch dann die direkte LAN-IP wenn das UI ueber Domain/Proxy
  geoeffnet wurde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:53:12 +02:00

228 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { Server, RefreshCw, Copy, CheckCircle, Trash2 } from 'lucide-react'
import { api } from '../lib/api'
import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
import { Button } from './ui/Button'
import { Input } from './ui/Input'
interface WorkerInfo {
name: string
encoders: string[]
last_seen?: string
online?: boolean
info?: {
hostname?: string
ip?: string
makemkv?: string
handbrake?: string
}
}
const ENCODER_LABELS: Record<string, string> = {
'cpu-x264': 'CPU · H.264',
'cpu-x265': 'CPU · H.265',
'vaapi': 'GPU · VAAPI (AMD/Intel)',
'nvenc': 'GPU · NVENC (NVIDIA)',
}
function relativeZeit(iso?: string): string {
if (!iso) return 'nie'
const sekunden = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (sekunden < 90) return 'gerade eben'
if (sekunden < 3600) return `vor ${Math.floor(sekunden / 60)} min`
return `vor ${Math.floor(sekunden / 3600)} h`
}
export default function WorkerVerwaltung() {
const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [kopiert, setKopiert] = useState(false)
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
// BEWUSST leer: kein window.location.hostname-Default. Der füllte früher
// die Adresse, über die man das UI geöffnet hat (z. B. den eigenen PC hinter
// einem Proxy/Forward) — und die ist NICHT zwingend die direkte LAN-IP der
// Rippy-Maschine, die der Worker für Redis/Postgres braucht. Lieber bewusst
// eintragen lassen als eine falsche Adresse vorgaukeln.
const [rippyHost, setRippyHost] = useState('')
const { toast } = useToast()
// Für die Befehle: leeres Feld → sichtbarer Platzhalter (Befehl ist dann
// erkennbar unvollständig, statt still auf die falsche Adresse zu zeigen).
const hostFuerBefehl = rippyHost || '<RIPPY-IP>'
const siehtOeffentlichAus =
rippyHost !== '' &&
!/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) &&
!rippyHost.endsWith('.local') &&
rippyHost !== 'localhost' &&
rippyHost.includes('.')
const laden = (manuell = false) => {
api.get('/capabilities')
.then(r => {
setWorkers(r.data.workers || [])
if (manuell) toast('success', 'Worker-Liste aktualisiert')
})
.catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') })
}
useEffect(() => {
laden()
const interval = setInterval(laden, 15000)
return () => clearInterval(interval)
}, [])
const installBefehl = variante === 'linux'
? [
`git clone <rippy-repo-url> rippy && cd rippy`,
`RIPPY_HOST=${hostFuerBefehl} WORKER_NAME=mein-pc \\`,
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
].join('\n')
: [
`iwr http://${hostFuerBefehl}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
` -RippyHost ${hostFuerBefehl} -WorkerName mein-pc`,
].join('\n')
const kopieren = () => {
navigator.clipboard.writeText(installBefehl).then(() => {
setKopiert(true)
setTimeout(() => setKopiert(false), 2000)
})
}
return (
<div className="space-y-6">
{/* Verbundene Worker */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server size={20} className="text-amber-500" />
<span>Verbundene Worker</span>
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Worker neu laden">
<RefreshCw size={16} />
</Button>
</CardHeader>
<CardContent>
{workers.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
) : (
<div className="space-y-2.5">
{workers.map(w => (
<div key={w.name} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
<div className="flex items-center gap-3 min-w-0">
<span
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${w.online ? 'bg-emerald-500 shadow-sm shadow-emerald-500/50' : 'bg-rose-500'}`}
title={w.online ? 'erreichbar (Ping OK)' : 'nicht erreichbar'}
/>
<Server size={18} className="text-slate-400 dark:text-slate-500" />
<div className="min-w-0">
<p className="text-sm font-medium truncate text-slate-900 dark:text-slate-200">{w.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap gap-1 justify-end">
{w.encoders.map(e => (
<span key={e} className={`text-xs px-2.5 py-0.5 rounded-md font-medium border ${
e.startsWith('cpu')
? 'bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
}`}>
{ENCODER_LABELS[e] || e}
</span>
))}
</div>
{!w.online && (
<button
onClick={() => {
api.delete(`/workers/${encodeURIComponent(w.name)}`)
.then(() => { toast('success', `${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() })
.catch(() => toast('error', 'Entfernen fehlgeschlagen'))
}}
title="Verwaisten Eintrag entfernen"
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Neue Maschine anbinden */}
<Card>
<CardHeader>
<CardTitle>Maschine als Encoding-Worker anbinden</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="max-w-md">
<Input
label="LAN-IP der Rippy-Maschine (Docker-Host)"
value={rippyHost}
onChange={e => setRippyHost(e.target.value.trim())}
placeholder="z. B. 192.168.178.162"
/>
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
Die IP der Maschine, auf der <strong>Rippy selbst</strong> (Docker) läuft meist deine
Server-/NAS-VM. <strong>Nicht</strong> die IP des PCs, auf dem der Worker läuft, und auch
dann diese direkte LAN-IP eintragen, wenn du dieses UI über eine andere Adresse
(Domain/Proxy/Portweiterleitung) geöffnet hast. Der Worker spricht Redis/Postgres direkt
an (Ports 6379/5432) das klappt nur mit der echten LAN-IP im Heimnetz.
</p>
{siehtOeffentlichAus && (
<p className="text-xs mt-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-300">
Das sieht nach einer externen Domain aus (Reverse-Proxy) die geht nicht. Bitte die
<strong> LAN-IP</strong> der Rippy-Maschine eintragen, z. B. 192.168.178.162.
</p>
)}
</div>
<div className="flex gap-2">
{([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
<Button
key={wert}
variant={variante === wert ? 'amber' : 'secondary'}
size="sm"
onClick={() => setVariante(wert)}
>
{label}
</Button>
))}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
{variante === 'linux'
? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
: 'In einer PowerShell auf dem Windows-PC ausführen (braucht nur Python 3.10+ — kein Docker, kein git). Der Installer holt Worker-Code und HandBrake automatisch.'}
</p>
<div className="relative rounded-xl p-4 font-mono text-xs whitespace-pre overflow-x-auto bg-slate-900 text-slate-200 border border-slate-800">
{installBefehl}
<button
onClick={kopieren}
className="absolute top-3 right-3 p-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
title="Kopieren"
>
{kopiert ? <CheckCircle size={15} className="text-emerald-400" /> : <Copy size={15} />}
</button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Beide Varianten bedienen nur die Kompressions-Queue gerippt wird immer hier, wo das Laufwerk hängt.
</p>
</CardContent>
</Card>
</div>
)
}