fix(ui+api): das "Neuladen" war HTTP 429 - und "Neu" komprimierte immer
Ampel / ampel (push) Successful in 30s
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:
@@ -0,0 +1,165 @@
|
||||
import { AlertTriangle, HelpCircle, RotateCcw, Disc } from 'lucide-react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
|
||||
/*
|
||||
* Was wird bei „Neu" eigentlich wiederholt?
|
||||
*
|
||||
* Commander-Frage 26.07.2026: „Hier gibt es den Button ‚neu' aber WAS wird dann
|
||||
* gemacht? Komprimierung? Neu Gerippt? Das muss ja je nach fehlgeschlagenem Job
|
||||
* eine Option anbieten." Bis dahin hieß der Knopf nur „Neu" und rief immer die
|
||||
* Kompression — auch bei einem Job, dessen RIP abgebrochen war. Aus 5,1 GB
|
||||
* Bruchstück (von rund 40 GB) wäre brav ein Film geworden, der bei 12 % endet.
|
||||
*
|
||||
* Dieser Dialog nennt beim Namen, was passiert, und in welchen Punkten sich die
|
||||
* beiden Wege unterscheiden: Braucht es die Disc? Wie lange dauert es? Was
|
||||
* passiert mit dem, was schon auf der Platte liegt?
|
||||
*
|
||||
* Die Phase kommt aus api/phasen.py. Kennt Rippy sie nicht (Jobs von vor der
|
||||
* Einführung der Marke), wird nicht geraten — dann stehen beide Wege da, und
|
||||
* die Größe der Rohdaten ist die Entscheidungshilfe: Wer ungefähr eine
|
||||
* Disc-Größe daliegen sieht, hatte einen fertigen Rip.
|
||||
*/
|
||||
|
||||
interface RetryDialogProps {
|
||||
offen: boolean
|
||||
titel: string
|
||||
plan: 'transcode' | 'rip' | 'unklar'
|
||||
rohdaten: { gb: number, dateien: number, pfade: string[] } | null
|
||||
onClose: () => void
|
||||
onStart: (art: 'transcode' | 'rip') => void
|
||||
}
|
||||
|
||||
function Rohdaten({ rohdaten }: { rohdaten: RetryDialogProps['rohdaten'] }) {
|
||||
if (!rohdaten) return null
|
||||
if (!rohdaten.gb) {
|
||||
return (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Auf der Platte liegt zu diesem Job nichts (mehr) — neu rippen ist damit
|
||||
der einzige Weg.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Auf der Platte liegen <strong>{rohdaten.gb} GB</strong> Rohdaten
|
||||
{rohdaten.dateien > 0 ? ` in ${rohdaten.dateien} Datei${rohdaten.dateien === 1 ? '' : 'en'}` : ''}.
|
||||
{' '}Eine Blu-ray bringt roh etwa 25–45 GB mit, eine 4K-UHD 50–100 GB, eine
|
||||
DVD 4–8 GB. Passt die Zahl dazu, war der Rip fertig.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RetryDialog({
|
||||
offen, titel, plan, rohdaten, onClose, onStart,
|
||||
}: RetryDialogProps) {
|
||||
const name = titel || 'Dieser Job'
|
||||
|
||||
return (
|
||||
<Modal isOpen={offen} onClose={onClose} maxWidth="lg">
|
||||
{plan === 'transcode' && (
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
<RotateCcw className="text-indigo-500 w-8 h-8 flex-shrink-0" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
„{name}" neu komprimieren?
|
||||
</h2>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
Der Rip war fertig — abgebrochen ist erst die Kompression. Rippy
|
||||
nimmt die vorhandene Roh-Datei und komprimiert sie noch einmal.
|
||||
<strong> Die Disc wird nicht gebraucht</strong>, das Laufwerk
|
||||
bleibt frei, und die Stunde fürs Rippen fällt nicht erneut an.
|
||||
</p>
|
||||
<Rohdaten rohdaten={rohdaten} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
<Button variant="ghost" onClick={() => onStart('rip')}>
|
||||
<Disc size={14} /> Doch von der Disc neu rippen
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => onStart('transcode')}>
|
||||
<RotateCcw size={14} /> Neu komprimieren
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{plan === 'rip' && (
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertTriangle className="text-amber-500 w-8 h-8 flex-shrink-0" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
„{name}" neu rippen?
|
||||
</h2>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
Hier ist der <strong>Rip selbst</strong> abgebrochen. Was davon
|
||||
auf der Platte liegt, ist ein Bruchstück — komprimieren würde
|
||||
daraus einen Film machen, der mitten drin aufhört. Rippy liest
|
||||
die Disc deshalb von vorn.
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
<strong>Die Disc muss dafür im Laufwerk liegen.</strong> Nach dem
|
||||
Fehlschlag wurde sie möglicherweise ausgeworfen. Titel, Ablage,
|
||||
Sprachwahl und Encoder-Worker werden vom alten Job übernommen.
|
||||
</p>
|
||||
<Rohdaten rohdaten={rohdaten} />
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Der alte Eintrag bleibt zum Nachlesen stehen; das Bruchstück
|
||||
lässt sich über den Papierkorb daran mitlöschen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
<Button variant="amber" onClick={() => onStart('rip')}>
|
||||
<Disc size={14} /> Neu rippen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{plan === 'unklar' && (
|
||||
<>
|
||||
<div className="flex items-start gap-4">
|
||||
<HelpCircle className="text-indigo-500 w-8 h-8 flex-shrink-0" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
„{name}": was soll wiederholt werden?
|
||||
</h2>
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
Bei diesem Job kann Rippy nicht mehr feststellen, ob der Rip
|
||||
fertig war oder mitten drin abbrach — er ist älter als der
|
||||
Vermerk dafür. Deshalb wird hier nicht geraten:
|
||||
</p>
|
||||
<ul className="text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-1 list-disc pl-5">
|
||||
<li>
|
||||
<strong>Neu komprimieren</strong> — schnell, braucht die Disc
|
||||
nicht. Richtig, wenn der Rip durchlief.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Neu rippen</strong> — dauert wieder eine Weile und
|
||||
braucht die Disc im Laufwerk. Immer richtig, nur teurer.
|
||||
</li>
|
||||
</ul>
|
||||
<Rohdaten rohdaten={rohdaten} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
{!!rohdaten?.gb && (
|
||||
<Button variant="primary" onClick={() => onStart('transcode')}>
|
||||
<RotateCcw size={14} /> Neu komprimieren
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="amber" onClick={() => onStart('rip')}>
|
||||
<Disc size={14} /> Neu rippen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user