67a4a76afa
Ampel / ampel (push) Successful in 28s
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>
228 lines
9.9 KiB
TypeScript
228 lines
9.9 KiB
TypeScript
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>
|
||
)
|
||
}
|