Dashboard-Korrektur-Popup, Worker-Tab mit Live-Ping, Herzschlag, Anzeige-Fixes
Ampel / ampel (push) Failing after 29s
Ampel / ampel (push) Failing after 29s
Commander-Wuensche (23.07. Nacht):
- "Nicht korrekt?!"-Knopf direkt an der Disc-Karte: Korrektur-Suche als
Popup (MetadataKorrektur) — kein Seitenwechsel mehr noetig; Wahl gilt
per Disc-Fingerabdruck weiter 30 Tage
- Einstellungen -> Worker: verbundene Encoding-Maschinen mit ECHTER
Erreichbarkeit (Celery-Ping + minuetlicher Herzschlag/last_seen),
Encoder-Badges, Copy-Paste-Anbindung fuer neue Maschinen
(Linux/Docker + Docker Desktop; SSH-Ein-Klick als Ausbaustufe)
- JSX-0-Falle gefixt: {0 && ...} renderte nackte "0" unter GENRE/LAUFZEIT
- ruff.toml: Regelsatz gepinnt — lokal aktualisierte Ruff-Version zog
ploetzlich Stilregeln und faerbte den Baum rot; Ampel und lokal
urteilen jetzt identisch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+19
-3
@@ -377,12 +377,28 @@ async def cancel_job(job_id: str):
|
||||
|
||||
@app.get("/capabilities")
|
||||
async def capabilities():
|
||||
"""Welche Encoder sind auf welchen Workern WIRKLICH verfügbar?
|
||||
"""Welche Encoder sind auf welchen Workern WIRKLICH verfügbar — inkl.
|
||||
Live-Erreichbarkeit (Celery-Ping + Herzschlag-Alter).
|
||||
|
||||
Jeder Worker meldet sich beim Start selbst (caps.py) — auch optionale
|
||||
Jeder Worker meldet sich selbst (caps.py, minütlich) — auch optionale
|
||||
Remote-GPU-Worker tauchen hier automatisch auf.
|
||||
"""
|
||||
return {"workers": await asyncio.to_thread(db.list_workers)}
|
||||
def sammle():
|
||||
zeilen = db.list_workers()
|
||||
try:
|
||||
antworten = celery_client.control.ping(timeout=1.0) or []
|
||||
online_namen = {
|
||||
knoten.split("@", 1)[-1]
|
||||
for antwort in antworten
|
||||
for knoten in antwort.keys()
|
||||
}
|
||||
except Exception:
|
||||
online_namen = set()
|
||||
for zeile in zeilen:
|
||||
zeile["online"] = zeile["name"] in online_namen
|
||||
return zeilen
|
||||
|
||||
return {"workers": await asyncio.to_thread(sammle)}
|
||||
|
||||
|
||||
class MountRequest(BaseModel):
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-rea
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
import RipTargetModal from './RipTargetModal'
|
||||
import MetadataKorrektur from './MetadataKorrektur'
|
||||
|
||||
interface DiscInfo {
|
||||
title: string
|
||||
@@ -41,6 +42,7 @@ export default function DeviceDiscovery() {
|
||||
const [actionFeedback, setActionFeedback] = useState<string | null>(null)
|
||||
const [actionBusy, setActionBusy] = useState(false)
|
||||
const [modalDevice, setModalDevice] = useState<Device | null>(null)
|
||||
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
|
||||
const { theme } = useDarkMode()
|
||||
|
||||
const refreshDevices = async () => {
|
||||
@@ -176,13 +178,21 @@ export default function DeviceDiscovery() {
|
||||
{device.disc!.metadata.overview}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setModalDevice(device)}
|
||||
disabled={actionBusy}
|
||||
className="mt-3 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
||||
>
|
||||
Rippen starten
|
||||
</button>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<button
|
||||
onClick={() => setModalDevice(device)}
|
||||
disabled={actionBusy}
|
||||
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
||||
>
|
||||
Rippen starten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setKorrekturDevice(device)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'bg-slate-800 text-amber-300 hover:bg-slate-700' : 'bg-slate-100 text-amber-700 hover:bg-slate-200'}`}
|
||||
>
|
||||
Nicht korrekt?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -313,6 +323,15 @@ export default function DeviceDiscovery() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* „Nicht korrekt?"-Popup: Korrektur ohne Seitenwechsel */}
|
||||
<MetadataKorrektur
|
||||
devicePath={korrekturDevice?.path || ''}
|
||||
vorschlag={korrekturDevice?.disc?.title}
|
||||
isOpen={korrekturDevice !== null}
|
||||
onClose={() => setKorrekturDevice(null)}
|
||||
onUebernommen={refreshDevices}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Search, Loader2, X, Film } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
|
||||
// „Nicht korrekt?!"-Popup (Commander-Wunsch 23.07.): Korrektur direkt am
|
||||
// Dashboard — suchen, richtigen Treffer anklicken, Rippy merkt sich die Wahl
|
||||
// für diese Disc (30 Tage, per Disc-Fingerabdruck).
|
||||
|
||||
interface SuchKandidat {
|
||||
title: string
|
||||
year?: number
|
||||
poster?: string
|
||||
overview?: string
|
||||
type: string
|
||||
source: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
devicePath: string
|
||||
vorschlag?: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onUebernommen: () => void
|
||||
}
|
||||
|
||||
export default function MetadataKorrektur({ devicePath, vorschlag, isOpen, onClose, onUebernommen }: Props) {
|
||||
const [suchbegriff, setSuchbegriff] = useState('')
|
||||
const [kandidaten, setKandidaten] = useState<SuchKandidat[]>([])
|
||||
const [laeuft, setLaeuft] = useState(false)
|
||||
const [fehler, setFehler] = useState<string | null>(null)
|
||||
const { theme } = useDarkMode()
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSuchbegriff(vorschlag || '')
|
||||
setKandidaten([])
|
||||
setFehler(null)
|
||||
}
|
||||
}, [isOpen, vorschlag])
|
||||
|
||||
const suchen = async () => {
|
||||
if (!suchbegriff) return
|
||||
setLaeuft(true)
|
||||
setFehler(null)
|
||||
try {
|
||||
const r = await api.get('/metadata/search', { params: { q: suchbegriff } })
|
||||
setKandidaten(r.data)
|
||||
if (r.data.length === 0) setFehler('Nichts gefunden — anderen Suchbegriff probieren.')
|
||||
} catch {
|
||||
setFehler('Suche fehlgeschlagen.')
|
||||
} finally {
|
||||
setLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
const waehlen = async (k: SuchKandidat) => {
|
||||
try {
|
||||
await api.post('/metadata/override', {
|
||||
device_path: devicePath,
|
||||
title: k.title,
|
||||
year: k.year,
|
||||
poster: k.poster,
|
||||
overview: k.overview,
|
||||
type: k.type,
|
||||
source: k.source,
|
||||
id: k.id,
|
||||
})
|
||||
onUebernommen()
|
||||
onClose()
|
||||
} catch {
|
||||
setFehler('Übernehmen fehlgeschlagen.')
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className={`w-full max-w-2xl max-h-[85vh] flex flex-col rounded-xl shadow-2xl ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
||||
<div className={`px-6 py-4 border-b flex items-center justify-between ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
||||
<div>
|
||||
<h2 className={`text-lg font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Richtigen Titel wählen</h2>
|
||||
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
Deine Wahl gilt für diese Disc — auch beim nächsten Einlegen.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
value={suchbegriff}
|
||||
onChange={e => setSuchbegriff(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && suchen()}
|
||||
autoFocus
|
||||
placeholder="Titel eingeben…"
|
||||
className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-slate-300 text-slate-900'}`}
|
||||
/>
|
||||
<button
|
||||
onClick={suchen}
|
||||
disabled={!suchbegriff || laeuft}
|
||||
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{laeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{fehler && <p className="text-sm text-rose-500 mt-3">{fehler}</p>}
|
||||
|
||||
{kandidaten.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
|
||||
{kandidaten.map((k, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => waehlen(k)}
|
||||
className={`text-left rounded-lg border overflow-hidden transition-all hover:border-indigo-500 hover:shadow-lg ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}
|
||||
>
|
||||
{k.poster ? (
|
||||
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover" />
|
||||
) : (
|
||||
<div className={`w-full h-40 flex items-center justify-center ${theme === 'dark' ? 'bg-slate-800 text-slate-600' : 'bg-slate-100 text-slate-400'}`}>
|
||||
<Film size={32} />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-2">
|
||||
<p className={`text-xs font-semibold leading-tight line-clamp-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{k.title}</p>
|
||||
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
{k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Server, RefreshCw, Copy, CheckCircle } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
|
||||
// Worker-Verwaltung (Commander-Anforderung 23.07.): Welche Maschinen encoden,
|
||||
// sind sie erreichbar, und wie bindet man eine neue an? Ein Worker "installiert
|
||||
// sich" über einen Copy-Paste-Befehl auf der Zielmaschine — danach taucht er
|
||||
// hier automatisch auf und zieht sich Kompressions-Jobs aus der Warteschlange.
|
||||
|
||||
interface WorkerInfo {
|
||||
name: string
|
||||
encoders: string[]
|
||||
last_seen?: string
|
||||
online?: boolean
|
||||
}
|
||||
|
||||
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 { theme } = useDarkMode()
|
||||
|
||||
const laden = () => {
|
||||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
laden()
|
||||
const interval = setInterval(laden, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const rippyHost = window.location.hostname
|
||||
const installBefehl = [
|
||||
`git clone <rippy-repo-url> rippy && cd rippy`,
|
||||
`RIPPY_HOST=${rippyHost} docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
|
||||
].join('\n')
|
||||
|
||||
const kopieren = () => {
|
||||
navigator.clipboard.writeText(installBefehl).then(() => {
|
||||
setKopiert(true)
|
||||
setTimeout(() => setKopiert(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Verbundene Worker */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verbundene Worker</h3>
|
||||
<button onClick={laden} className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{workers.length === 0 ? (
|
||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch kein Worker gemeldet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{workers.map(w => (
|
||||
<div key={w.name} className={`flex items-center justify-between p-3 rounded-lg ${theme === 'dark' ? 'bg-slate-800' : 'bg-slate-50'}`}>
|
||||
<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' : 'bg-rose-500'}`}
|
||||
title={w.online ? 'erreichbar (Ping OK)' : 'nicht erreichbar'}
|
||||
/>
|
||||
<Server size={18} className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'} />
|
||||
<div className="min-w-0">
|
||||
<p className={`text-sm font-mono truncate ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{w.name}</p>
|
||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 justify-end">
|
||||
{w.encoders.map(e => (
|
||||
<span key={e} className={`text-xs px-2 py-0.5 rounded ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}>
|
||||
{ENCODER_LABELS[e] || e}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Neue Maschine anbinden */}
|
||||
<div className={`rounded-lg p-4 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}>
|
||||
<h3 className={`font-medium mb-1 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3>
|
||||
<p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Auf der Zielmaschine (Linux mit Docker, oder Windows mit Docker Desktop) diesen
|
||||
Befehl ausführen — der Worker verbindet sich, taucht oben in der Liste auf und
|
||||
übernimmt ab dann automatisch Kompressions-Jobs. GPU vorhanden? Wird erkannt
|
||||
und angezeigt. Voraussetzung: die Maschine erreicht diesen Rippy-Host im Netz.
|
||||
</p>
|
||||
<div className={`relative rounded-lg p-3 font-mono text-xs whitespace-pre overflow-x-auto ${theme === 'dark' ? 'bg-slate-950 text-slate-300' : 'bg-slate-900 text-slate-200'}`}>
|
||||
{installBefehl}
|
||||
<button
|
||||
onClick={kopieren}
|
||||
className="absolute top-2 right-2 p-1.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
|
||||
title="Kopieren"
|
||||
>
|
||||
{kopiert ? <CheckCircle size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Eine geführte Ein-Klick-Installation (SSH) ist als Ausbaustufe geplant.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -337,7 +337,9 @@ export default function MetadataPreview() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.metadata.genres && (
|
||||
{/* .length-Guards: {[] && …} rendert die leere Sektion,
|
||||
{0 && …} sogar eine nackte "0" — klassische JSX-Falle */}
|
||||
{result.metadata.genres && result.metadata.genres.length > 0 && (
|
||||
<div>
|
||||
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Genre</h3>
|
||||
<div className="flex flex-wrap gap-2 transition-colors duration-300">
|
||||
@@ -353,7 +355,7 @@ export default function MetadataPreview() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.metadata.runtime && (
|
||||
{(result.metadata.runtime ?? 0) > 0 && (
|
||||
<div>
|
||||
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Laufzeit</h3>
|
||||
<p className={`transition-colors duration-300 ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>{Math.floor(result.metadata.runtime / 60)}h {result.metadata.runtime % 60}m</p>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive }
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
import StorageMounts from '../components/StorageMounts'
|
||||
import WorkerVerwaltung from '../components/WorkerVerwaltung'
|
||||
|
||||
interface SettingsState {
|
||||
tmdbApiKey: string
|
||||
@@ -38,7 +39,7 @@ const defaultSettings: SettingsState = {
|
||||
keepOriginal: false,
|
||||
}
|
||||
|
||||
type SettingsTab = 'ripping' | 'verarbeitung' | 'speicherziele' | 'apis' | 'benachrichtigungen'
|
||||
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen'
|
||||
|
||||
interface WorkerInfo {
|
||||
name: string
|
||||
@@ -125,6 +126,7 @@ export default function SettingsPage() {
|
||||
<div className={`flex transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-b`}>
|
||||
<TabButton icon={Disc} label="Ripping" isActive={activeTab === 'ripping'} onClick={() => setActiveTab('ripping')} darkMode={theme === 'dark'} />
|
||||
<TabButton icon={Cpu} label="Verarbeitung" isActive={activeTab === 'verarbeitung'} onClick={() => setActiveTab('verarbeitung')} darkMode={theme === 'dark'} />
|
||||
<TabButton icon={Cpu} label="Worker" isActive={activeTab === 'worker'} onClick={() => setActiveTab('worker')} darkMode={theme === 'dark'} />
|
||||
<TabButton icon={HardDrive} label="Speicherziele" isActive={activeTab === 'speicherziele'} onClick={() => setActiveTab('speicherziele')} darkMode={theme === 'dark'} />
|
||||
<TabButton icon={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} />
|
||||
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
|
||||
@@ -258,6 +260,15 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
{/* Worker: Encoding-Maschinen im Netz */}
|
||||
{activeTab === 'worker' && <section>
|
||||
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||
<Cpu size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||
Encoding-Worker
|
||||
</h2>
|
||||
<WorkerVerwaltung />
|
||||
</section>}
|
||||
|
||||
{/* Speicherziele: Netzwerk-Mounts aus dem UI */}
|
||||
{activeTab === 'speicherziele' && <section>
|
||||
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||
|
||||
@@ -31,9 +31,21 @@ import db # noqa: E402
|
||||
|
||||
@worker_ready.connect
|
||||
def melde_faehigkeiten(**kwargs):
|
||||
"""Beim Start: eigene Encoder-Fähigkeiten in die DB melden (UI-Anzeige)."""
|
||||
try:
|
||||
db.init_db()
|
||||
db.save_worker(socket.gethostname(), caps.erkenne_encoder())
|
||||
except Exception as e: # DB noch nicht da → nicht den Start verhindern
|
||||
print(f"Fähigkeiten-Meldung fehlgeschlagen: {e}")
|
||||
"""Beim Start + minütlich: Encoder-Fähigkeiten und Lebenszeichen melden.
|
||||
|
||||
last_seen ist die Basis der Erreichbarkeits-Anzeige im UI — ein Worker,
|
||||
der sich 2 Minuten nicht meldet, gilt als offline.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
def herzschlag():
|
||||
while True:
|
||||
try:
|
||||
db.init_db()
|
||||
db.save_worker(socket.gethostname(), caps.erkenne_encoder())
|
||||
except Exception as e: # DB weg → weiterversuchen, nicht sterben
|
||||
print(f"Fähigkeiten-Meldung fehlgeschlagen: {e}")
|
||||
time.sleep(60)
|
||||
|
||||
threading.Thread(target=herzschlag, daemon=True, name="worker-herzschlag").start()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Ruff-Regelsatz festgepinnt (23.07.2026): identisches Urteil auf jedem
|
||||
# Rechner und in der Ampel. Ohne Pin zog eine lokal aktualisierte Ruff-Version
|
||||
# plötzlich Stilregeln (UP/I/BLE/FURB …) und färbte den ganzen Baum rot —
|
||||
# der Regelsatz hier ist der bewährte Fehler-Fokus (Syntax, Imports, Bugs).
|
||||
[lint]
|
||||
select = ["E4", "E7", "E9", "F"]
|
||||
Reference in New Issue
Block a user