fix(ui+api): das "Neuladen" war HTTP 429 - und "Neu" komprimierte immer
Ampel / ampel (push) Successful in 30s

Zwei Commander-Befunde, beide mit derselben Wurzel: Rippy hat sich selbst
ausgebremst und dann geschwiegen.

## "Wenn der Worker installiert ist, wird dieser Bereich oft neu geladen"

Gemessen statt geraten. Die Antworten von /jobs und /capabilities waren ueber
zwanzig Sekunden byteweise identisch, alle Endpunkte antworteten unter 30 ms -
es wurde also gar nichts neu geladen. Im nginx-Log standen dagegen 97 Antworten
mit HTTP 429.

Drei Fehler griffen ineinander:

1. Das Limit war zu klein fuer Rippy selbst: 100 Anfragen/min, waehrend ein
   offener Tab 111/min verursacht (Dashboard 75 + Log-Kasten 24 + Laufwerke 12)
   und der Windows-Tray weitere 12/min dazulegt.
2. Der nginx gab die Client-Adresse nicht weiter. Fuer die API kam damit ALLES
   von 172.19.0.6 - Browser, zweiter Tab und Tray teilten sich einen Eimer
   (812 von 876 Anfragen). Das erklaert die Kopplung an den Worker: tray.py
   fragt /api/jobs ueber Port 80, also durch denselben Proxy.
3. Ein abgewiesener Abruf leerte das UI. `catch(() => [])` heisst "es gibt
   keine Jobs" - richtig waere "ich weiss gerade nichts Neues". Fuer einen Takt
   stand "Keine Jobs", die Zaehler sprangen auf (0), vier Sekunden spaeter war
   alles zurueck.

Behoben: X-Real-IP im nginx, Grenze auf 600/min mit vorgerechneter Herleitung,
jeder Fehlschlag laesst den alten Stand stehen (null statt []), axios bekommt
eine Zeitgrenze, und das Dashboard trennt schnelle Daten (Jobs/Laufwerke, 4 s)
von langsamen (Hardware/Worker/Ablagen, 12 s) - 75/min werden zu 30/min.
Ein greifendes Limit steht ab jetzt im Log, gedrosselt auf eine Meldung pro
Client und Minute.

## "Hier gibt es den Button 'neu' aber WAS wird dann gemacht?"

Immer die Komprimierung - auch bei einem Job, dessen RIP abgebrochen war. Am
26.07.2026 waeren aus 5,1 GB Bruchstueck (von rund 40 GB) brav ein Film
geworden, der bei 12 % aufhoert.

Die Phase war nach `status = "failed"` nicht mehr feststellbar, also wird sie
jetzt vermerkt (rip_fertig in den Job-Metadaten: false beim Rip-Start, true bei
der Uebergabe an die Kompression). Daraus folgt die Beschriftung: "Neu
komprimieren", "Neu rippen" - oder bei Bestandsjobs ohne Vermerk ein Dialog,
der beide Wege erklaert und die Groesse der Rohdaten als Entscheidungshilfe
nennt. Geraten wird nicht. Fuer den Rip-Fall gibt es POST
/jobs/{id}/retry-rip: neuer Job mit neuer ID (sonst laege das Bruchstueck im
Roh-Verzeichnis des neuen Rips), Titel/Ablage/Sprachwahl uebernommen, mit
ehrlicher Absage wenn keine Disc im Laufwerk liegt.

10 neue Tests (289 gruen), darunter eine Kopplungspruefung: der Name der
Phasen-Marke muss in worker/tasks.py und api/phasen.py zusammenpassen - genau
diese Sorte Auseinanderdriften hat die Zombie-Erkennung ein Release lang blind
gemacht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-26 16:49:10 +02:00
parent f7555a17d1
commit bfb13f44a5
11 changed files with 811 additions and 42 deletions
+142 -32
View File
@@ -9,6 +9,7 @@ import ConfirmDialog from '../components/ConfirmDialog'
import DeviceDiscovery from '../components/DeviceDiscovery'
import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection'
import RetryDialog from '../components/RetryDialog'
interface JobMeta {
year?: number
@@ -30,6 +31,8 @@ interface Job {
progress: number
title?: string
can_retry?: boolean
// "transcode" | "rip" | "unklar" | "" — welche Wiederholung passt (phasen.py)
retry_art?: string
meta?: JobMeta | null
// Restzeit von der API (eta.py). '' = noch keine Aussage möglich — dann wird
// bewusst nichts angezeigt statt einer erfundenen Zahl.
@@ -83,12 +86,26 @@ function posterUrl(meta?: JobMeta | null): string | null {
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
}
async function fetchJobs(): Promise<Job[]> {
/*
* Ein fehlgeschlagener Abruf gibt `null` — NICHT eine leere Liste.
*
* Commander-Befund 26.07.2026: „Wenn der Worker installiert ist, wird dieser
* Bereich oft neu geladen." Gemessen war es kein Neuladen: Die Antworten von
* /jobs und /capabilities waren über zwanzig Sekunden byteweise IDENTISCH, alle
* Endpunkte antworteten in unter 30 ms. Der Grund lag im Fehlerzweig — hier
* stand `return []`, und jeder einzelne verpasste Abruf setzte damit die
* Job-Liste auf LEER. Für einen Takt zeigte die Tabelle „Keine Jobs", die Zähler
* sprangen auf (0), vier Sekunden später war alles wieder da.
*
* Ein verpasster Abruf ist keine Nachricht über die Welt. Wer nichts Neues
* weiß, behält, was er wusste.
*/
async function fetchJobs(): Promise<Job[] | null> {
try {
const response = await api.get('/jobs')
return response.data
return Array.isArray(response.data) ? response.data : null
} catch {
return []
return null
}
}
@@ -102,6 +119,9 @@ export default function Dashboard() {
const [loeschKandidat, setLoeschKandidat] = useState<Job | null>(null)
const [rohdaten, setRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
const [rohdatenMitloeschen, setRohdatenMitloeschen] = useState(false)
// Job, für den ein neuer Versuch bestätigt werden soll (siehe wiederholen()).
const [neuKandidat, setNeuKandidat] = useState<Job | null>(null)
const [neuRohdaten, setNeuRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
@@ -130,6 +150,57 @@ export default function Dashboard() {
}
}
/*
* Der „Neu"-Knopf sagt jetzt, WAS er tut.
*
* Commander-Frage 26.07.2026: „Hier gibt es den Button neu' aber WAS wird
* dann gemacht? Komprimierung? Neu Gerippt?" — Antwort damals: immer
* Komprimierung, egal wie der Job gestorben war. Für einen abgebrochenen Rip
* war das falsch (5,1 GB Bruchstück von rund 40 GB).
*
* `job.retry_art` kommt aus api/phasen.py und kennt drei Fälle:
* 'transcode' → der Rip war fertig → „Neu komprimieren"
* 'rip' → der Rip starb → „Neu rippen" (Disc muss drin sein)
* 'unklar' → Bestandsjob → fragen, mit der Rohdaten-Größe als
* Entscheidungshilfe
*
* Sonderfall: Phase 'transcode', aber die Rohdaten sind fort (`can_retry`
* false) — dann hilft auch hier nur ein neuer Rip.
*/
const retryPlan = (job: Job): 'transcode' | 'rip' | 'unklar' => {
if (job.retry_art === 'transcode') return job.can_retry ? 'transcode' : 'rip'
if (job.retry_art === 'rip') return 'rip'
return 'unklar'
}
const wiederholen = async (job: Job) => {
setNeuKandidat(job)
setNeuRohdaten(null)
try {
const r = await api.get(`/jobs/${job.id}/rohdaten`)
setNeuRohdaten(r.data)
} catch {
setNeuRohdaten(null) // dann eben ohne Zahl fragen
}
}
const neuStarten = async (art: 'transcode' | 'rip') => {
const job = neuKandidat
if (!job) return
setNeuKandidat(null)
const weg = art === 'rip' ? 'retry-rip' : 'retry-transcode'
try {
await api.post(`/jobs/${job.id}/${weg}`)
toast('success', art === 'rip'
? 'Neuer Rip gestartet — der alte Eintrag bleibt zum Nachlesen stehen'
: 'Kompression neu eingereiht — die Disc wird nicht gebraucht')
const frisch = await fetchJobs()
if (frisch) setJobs(frisch)
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Fehlgeschlagen')
}
}
const jobLoeschen = async () => {
const job = loeschKandidat
if (!job) return
@@ -141,7 +212,8 @@ export default function Dashboard() {
const befreit = r.data?.rohdaten_geloescht_gb
toast('success', `${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`
+ (befreit ? `${befreit} GB Rohdaten gelöscht` : ''))
setJobs(await fetchJobs())
const frisch = await fetchJobs()
if (frisch) setJobs(frisch)
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
}
@@ -152,43 +224,71 @@ export default function Dashboard() {
try {
const r = await api.delete('/jobs')
toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`)
setJobs(await fetchJobs())
const frisch = await fetchJobs()
if (frisch) setJobs(frisch)
} catch {
toast('error', 'Aufräumen fehlgeschlagen')
}
}
/*
* ZWEI TAKTE statt einem.
*
* ⚠️ JEDER Abruf hier gibt bei Fehlschlag `null` — und `null` bedeutet „nichts
* Neues erfahren", nicht „es gibt nichts". Vor dem 26.07.2026 stand überall
* `catch(() => [])`: ein einziger verpasster Abruf leerte Job-Liste,
* Laufwerke und Ablagen, vier Sekunden später war alles wieder da. Genau das
* hat der Commander als „wird oft neu geladen" gemeldet. Siehe `fetchJobs`.
*
* Und verpasst wurde reichlich: Fünf Endpunkte alle vier Sekunden sind 75
* Anfragen pro Minute, dazu Log-Kasten und Laufwerks-Suche — die API wies bei
* ihrer damaligen Grenze von 100/min laufend mit HTTP 429 ab (gemessen: 97
* Abweisungen im Log). Die Grenze ist jetzt der echten Last angemessen
* (ratelimit.py), aber das rechtfertigt keine Verschwendung: Jobs und
* Laufwerke ändern sich sekündlich, die drei anderen praktisch nie.
* 75/min sind damit 15 + 15/min geworden.
*/
useEffect(() => {
const loadData = async () => {
const nichts = () => null
// Schnell (4 s): was sich während eines Rips wirklich bewegt.
const schnellLaden = async () => {
try {
const [jobsData, sysData, capsData, devData, ablData] = await Promise.all([
const [jobsData, 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,
// /system/info nur die Registrierung. Der Endpunkt ist seit v3.15
// 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(() => []),
// Die Ablageziele inkl. eingehängter Freigaben — ohne sie zeigte der
// Server-Status nur die Container-Platte (Commander-Befund).
api.get('/storage-targets').then(r => Array.isArray(r.data) ? r.data : []).catch(() => []),
api.get('/devices').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
])
setJobs(jobsData)
if (sysData) setSystemInfo(sysData)
setWorkersLive(capsData)
setLaufwerke(devData)
setAblagen(ablData)
if (jobsData) setJobs(jobsData)
if (devData) setLaufwerke(devData)
} finally {
setLoading(false)
}
}
loadData()
const interval = setInterval(loadData, 4000)
return () => clearInterval(interval)
// Langsam (12 s): Hardware, Worker-Liste, Ablagen. Ein Worker, der online
// geht, oder eine Freigabe, die wegbricht, darf drei Takte später auffallen.
const langsamLaden = async () => {
const [sysData, capsData, ablData] = await Promise.all([
api.get('/system/info').then(r => r.data).catch(nichts),
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
// /system/info nur die Registrierung.
api.get('/capabilities').then(r => r.data?.workers ?? null).catch(nichts),
// Die Ablageziele inkl. eingehängter Freigaben — ohne sie zeigte der
// Server-Status nur die Container-Platte (Commander-Befund).
api.get('/storage-targets').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
])
if (sysData) setSystemInfo(sysData)
if (capsData) setWorkersLive(capsData)
if (ablData) setAblagen(ablData)
}
schnellLaden()
langsamLaden()
const schnell = setInterval(schnellLaden, 4000)
const langsam = setInterval(langsamLaden, 12000)
return () => { clearInterval(schnell); clearInterval(langsam) }
}, [])
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
@@ -505,17 +605,17 @@ export default function Dashboard() {
</Button>
)}
{job.status === 'failed' && job.can_retry && (
{job.status === 'failed' && (
<Button
variant="secondary"
size="sm"
onClick={() => {
api.post(`/jobs/${job.id}/retry-transcode`)
.then(() => toast('success', 'Kompression neu eingereiht'))
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Fehlgeschlagen'))
}}
onClick={() => wiederholen(job)}
title="Fragt vorher, was genau wiederholt wird"
>
<RotateCcw size={13} /> Neu
<RotateCcw size={13} />
{retryPlan(job) === 'transcode' ? 'Neu komprimieren'
: retryPlan(job) === 'rip' ? 'Neu rippen'
: 'Neu …'}
</Button>
)}
@@ -773,6 +873,16 @@ export default function Dashboard() {
type="warning"
/>
{/* „Neu" nennt jetzt beim Namen, was es tut (siehe wiederholen()). */}
<RetryDialog
offen={neuKandidat !== null}
titel={neuKandidat?.title || neuKandidat?.id.slice(0, 8) || ''}
plan={neuKandidat ? retryPlan(neuKandidat) : 'unklar'}
rohdaten={neuRohdaten}
onClose={() => setNeuKandidat(null)}
onStart={neuStarten}
/>
{/* Entfernen eines EINZELNEN Jobs — mit der Zahl, die vorher fehlte. */}
<ConfirmDialog
isOpen={loeschKandidat !== null}