fix(ui): Leerer Bildschirm nach „Rippen starten" — ein halber Job riss alles mit
Commander: „wenn man auf rippen starten klickt passiert irgendwas, was das
Programm nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es
gibt auch keine fehlermeldung."
Nachgestellt: Dienst lokal gestartet, Disc-Karte geoeffnet, „Rippen starten"
geklickt. Browser-Konsole:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
## Er startet sehr wohl — man sieht es nur nie
Waehrend die Seite leer war, lief der Job (per API gemessen: status
`processing`, progress 12). Das „er startet nix" ist also der zweite Teil
desselben Fehlers: Die Oberflaeche war weg, bevor sie ihn zeigen konnte.
## Die Ursache
`_job_kurz` in `bus/waechter.py` trug nur `status`, `progress`, `title` und
`error` — **keinen `type`**. Das UI fuegt aus `job.created` einen halben Job
in seine Liste ein, `LiveLogSection` liest `job.type.toUpperCase()`, und React
baut bei einem Fehler im Zeichnen den GESAMTEN Baum ab. Es gab in diesem
Projekt keine einzige Fehlergrenze — also blieb ein leerer Bildschirm ohne
jede Meldung.
## Drei Reparaturen, weil es drei Fehler waren
1. **Das Ereignis traegt den Job.** `type`, `device` und `startTime` fahren
mit. Sie aendern sich ueber die Lebenszeit eines Jobs nie, kosten also kein
zusaetzliches Ereignis — und ohne `startTime` stand in der Jobliste
sekundenlang „Invalid Date".
2. **Das UI vertraegt sein Fehlen.** `LiveLogSection` und `TypeBadge` nahmen
einen vollstaendigen Job an. Zeile 108 derselben Datei hatte das
Fragezeichen laengst, Zeile 48 nicht.
3. **Eine Fehlergrenze.** Ein Fehler in einer Karte darf nicht den ganzen
Bildschirm mitnehmen — und schon gar nicht schweigend. Jetzt steht da, was
los ist, die Navigation bleibt bedienbar, und der Hinweis sagt das
Wichtigste: laufende Rips gehen weiter.
## Warum es niemand gefunden hat
`unterschiede()` bekommt die Kurzform schon fertig, und alle Tests reichen
ihre eigenen Woerterbuecher herein. **`_job_kurz` selbst war nie geprueft.**
Jetzt bewacht `UI_PFLICHTFELDER` den Vertrag mechanisch — es gibt kein
gemeinsames Typsystem zwischen Python und dem UI.
Gegengeprueft im Browser: derselbe Klick, keine Konsolenfehler, Job erscheint
als „Alle (1)".
833 Tests gruen, ruff sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d9780a4951
commit
27c9d9a4bb
@@ -8,6 +8,7 @@ import FirstRunWizard from './components/FirstRunWizard'
|
||||
import { api } from './lib/api'
|
||||
import { EventStreamProvider, useStrom } from './lib/useEventStream'
|
||||
import { BetriebProvider } from './lib/useBetrieb'
|
||||
import { Fehlergrenze } from './components/Fehlergrenze'
|
||||
|
||||
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||
|
||||
@@ -161,10 +162,19 @@ function AppInhalt() {
|
||||
|
||||
{/* Main Content Viewport */}
|
||||
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Je Seite eine eigene Grenze: Faellt eine aus, bleibt die
|
||||
Navigation stehen und man kommt woanders hin. Ohne das war am
|
||||
29.08.2026 nach einem Klick auf „Rippen starten" der ganze
|
||||
Bildschirm leer — samt Kopfzeile. */}
|
||||
<Fehlergrenze bereich={
|
||||
currentPage === 'dashboard' ? 'Das Dashboard'
|
||||
: currentPage === 'anleitung' ? 'Die Anleitung'
|
||||
: currentPage === 'logs' ? 'Die Protokolle' : 'Die Einstellungen'}>
|
||||
{currentPage === 'dashboard' && <Dashboard />}
|
||||
{currentPage === 'anleitung' && <AnleitungPage />}
|
||||
{currentPage === 'logs' && <LogsPage />}
|
||||
{currentPage === 'settings' && <SettingsPage />}
|
||||
</Fehlergrenze>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
@@ -178,10 +188,12 @@ export default function App() {
|
||||
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
|
||||
// compose ps" — einen Rat, den dort niemand befolgen kann.
|
||||
return (
|
||||
<Fehlergrenze>
|
||||
<BetriebProvider>
|
||||
<EventStreamProvider>
|
||||
<AppInhalt />
|
||||
</EventStreamProvider>
|
||||
</BetriebProvider>
|
||||
</Fehlergrenze>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Eine Fehlergrenze — damit ein Fehler nicht die ganze Oberfläche verschluckt.
|
||||
*
|
||||
* ## Der Befund des Commanders (29.08.2026)
|
||||
*
|
||||
* > „wenn man auf rippen starten klickt passiert irgendwas, was das Programm
|
||||
* > nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es gibt
|
||||
* > auch keine fehlermeldung."
|
||||
*
|
||||
* Nachgestellt und in der Browser-Konsole gemessen:
|
||||
*
|
||||
* TypeError: Cannot read properties of undefined (reading 'toUpperCase')
|
||||
*
|
||||
* Der Job war in Wirklichkeit angelegt und lief (gemessen: `processing`,
|
||||
* 12 %). Nur zeigte die Oberfläche das nie — sie war weg. Grund: React baut
|
||||
* bei einem Fehler im Zeichnen den GESAMTEN Baum ab, wenn ihn niemand
|
||||
* auffängt. Und aufgefangen hat ihn niemand: Es gab in diesem Projekt keine
|
||||
* einzige Fehlergrenze.
|
||||
*
|
||||
* ## Warum die eigentliche Reparatur woanders sitzt
|
||||
*
|
||||
* Die Ursache war ein Ereignis, das den halben Job schickte
|
||||
* (`rippy/bus/waechter.py`), und zwei Stellen, die einen vollständigen
|
||||
* annahmen. Das ist behoben. Diese Datei behebt etwas anderes: die WIRKUNG.
|
||||
*
|
||||
* Ein Programmierfehler in einer Karte darf den Rest des Bildschirms nicht
|
||||
* mitnehmen — und schon gar nicht schweigend. Der nächste Fehler dieser Art
|
||||
* kommt bestimmt; dann steht wenigstens da, was los ist, und der Rest der
|
||||
* Oberfläche bleibt bedienbar.
|
||||
*
|
||||
* Deshalb wird sie ZWEIMAL eingesetzt: einmal um die ganze Anwendung, und
|
||||
* einmal um den Job-Bereich allein. Fällt eine Karte aus, bleibt der Rest.
|
||||
*/
|
||||
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
/** Was hier kaputtgehen kann — steht in der Meldung. */
|
||||
bereich?: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
fehler: Error | null
|
||||
}
|
||||
|
||||
export class Fehlergrenze extends Component<Props, State> {
|
||||
state: State = { fehler: null }
|
||||
|
||||
static getDerivedStateFromError(fehler: Error): State {
|
||||
return { fehler }
|
||||
}
|
||||
|
||||
componentDidCatch(fehler: Error, info: ErrorInfo) {
|
||||
// In die Konsole, damit die Ursache auffindbar bleibt. Ein stiller
|
||||
// Fehler ist genau das, was diesen Befund so schwer gemacht hat.
|
||||
console.error('Rippy: Fehler im Bereich', this.props.bereich || 'Oberfläche',
|
||||
fehler, info.componentStack)
|
||||
}
|
||||
|
||||
neuLaden = () => {
|
||||
this.setState({ fehler: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.fehler) return this.props.children
|
||||
|
||||
return (
|
||||
<div className="m-4 p-5 rounded-xl border border-amber-500/40 bg-amber-500/10">
|
||||
<h2 className="font-semibold text-amber-700 dark:text-amber-300">
|
||||
{this.props.bereich
|
||||
? `${this.props.bereich} lässt sich gerade nicht anzeigen`
|
||||
: 'Diese Ansicht lässt sich gerade nicht anzeigen'}
|
||||
</h2>
|
||||
<p className="text-sm mt-2 text-slate-600 dark:text-slate-300">
|
||||
Der Fehler betrifft nur die Anzeige.{' '}
|
||||
<strong>Laufende Rips gehen weiter</strong> — Rippy arbeitet im
|
||||
Hintergrund, auch wenn dieser Bereich leer bleibt.
|
||||
</p>
|
||||
<p className="text-xs mt-3 font-mono text-slate-500 dark:text-slate-400 break-all">
|
||||
{this.state.fehler.message || String(this.state.fehler)}
|
||||
</p>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
onClick={this.neuLaden}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-amber-500 text-white hover:bg-amber-600"
|
||||
>
|
||||
Nochmal versuchen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
Seite neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,10 @@ export default function LiveLogSection() {
|
||||
<CardTitle>Live-Log</CardTitle>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{currentJob
|
||||
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
|
||||
// `type?.` ist hier kein Beiwerk: Ein frisch angelegter Job kann
|
||||
// noch ohne Typ ankommen, und ohne das Fragezeichen riss diese
|
||||
// eine Zeile am 29.08.2026 die GANZE Oberflaeche mit.
|
||||
? `Aktiver Job: ${currentJob.type?.toUpperCase() || 'Disc'} (${currentJob.progress ?? 0}%)`
|
||||
: 'Kein aktiver Job'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -25,9 +25,12 @@ interface TypeBadgeProps {
|
||||
}
|
||||
|
||||
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
|
||||
// Ein fehlender Typ darf kein Absturz sein. Am 29.08.2026 hat genau diese
|
||||
// Annahme (an anderer Stelle) die ganze Oberflaeche geleert: Ein frisch
|
||||
// angelegter Job kommt ueber den Ereignisstrom ohne `type` an.
|
||||
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
|
||||
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
||||
label: type.toUpperCase(),
|
||||
label: type ? type.toUpperCase() : 'DISC',
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ genau die Eigenschaft, die dem ersten Anlauf der SSE-Tests gefehlt hat
|
||||
(Ampel-Lauf 170 lief rot, weil ein Test eine Datenbank brauchte).
|
||||
"""
|
||||
|
||||
from rippy.bus.waechter import Waechter, laufwerks_unterschiede, unterschiede
|
||||
from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz, Waechter, laufwerks_unterschiede, unterschiede
|
||||
|
||||
|
||||
class FakeBus:
|
||||
@@ -277,3 +277,70 @@ def test_laufwerke_werden_seltener_abgefragt_als_jobs():
|
||||
w.einmal()
|
||||
w.einmal()
|
||||
assert len(aufrufe) == 1, "Laufwerke wurden mehrfach im selben Takt gelesen"
|
||||
|
||||
|
||||
# ── Der Vertrag mit der Oberflaeche (Befund 29.08.2026) ─────────────────
|
||||
#
|
||||
# Commander: „wenn man auf rippen starten klickt passiert irgendwas, was das
|
||||
# Programm nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es
|
||||
# gibt auch keine fehlermeldung."
|
||||
#
|
||||
# Nachgestellt und in der Browser-Konsole gemessen:
|
||||
#
|
||||
# TypeError: Cannot read properties of undefined (reading 'toUpperCase')
|
||||
#
|
||||
# Der Job lief in Wahrheit (gemessen: processing, 12 %). Aber `job.created`
|
||||
# trug keinen `type`; das UI fuegte einen halben Job ein, las
|
||||
# `job.type.toUpperCase()` — und weil es keine Fehlergrenze gab, riss der
|
||||
# Fehler die GANZE Oberflaeche mit.
|
||||
#
|
||||
# ⚠️ Warum es niemand gefunden hat: `unterschiede()` bekommt die Kurzform
|
||||
# schon fertig, die Tests oben reichen ihre eigenen Woerterbuecher herein.
|
||||
# **`_job_kurz` selbst war nie geprueft.** Diese Tests schliessen die Luecke.
|
||||
|
||||
|
||||
|
||||
def _db_zeile():
|
||||
"""Eine Job-Zeile, wie die API sie liefert (Felder aus /jobs)."""
|
||||
return {"id": "j1", "type": "bluray", "device": r"\\.\G:",
|
||||
"startTime": "2026-08-29T12:07:02", "endTime": None,
|
||||
"status": "processing", "progress": 12, "title": "Evangelion 2.22",
|
||||
"error": None, "meta": {"confidence": 0.3}}
|
||||
|
||||
|
||||
def test_ein_job_ereignis_traegt_alles_was_die_oberflaeche_braucht():
|
||||
"""DER Waechter. Fehlt hier ein Feld, ist der Bildschirm beim Commander
|
||||
leer — ohne Fehlermeldung."""
|
||||
kurz = _job_kurz(_db_zeile())
|
||||
for feld in UI_PFLICHTFELDER:
|
||||
assert feld in kurz, "%s fehlt — das UI muesste raten" % feld
|
||||
assert kurz[feld] is not None, "%s ist leer" % feld
|
||||
|
||||
|
||||
def test_der_typ_kommt_wirklich_durch():
|
||||
"""Der konkrete Fehler: `type` fehlte, das UI rief .toUpperCase() darauf."""
|
||||
assert _job_kurz(_db_zeile())["type"] == "bluray"
|
||||
|
||||
|
||||
def test_fehlende_felder_werden_zu_None_statt_zu_werfen():
|
||||
"""Eine unvollstaendige Zeile darf den Waechter nicht umbringen — er
|
||||
laeuft in der Ereignisschleife des Servers."""
|
||||
kurz = _job_kurz({"id": "j1"})
|
||||
assert kurz["type"] is None and kurz["status"] is None
|
||||
|
||||
|
||||
def test_die_kurzform_traegt_NUR_was_gebraucht_wird():
|
||||
"""Kein Nachziehen der ganzen Zeile: `meta` und `endTime` gehoeren nicht
|
||||
in ein Aenderungs-Ereignis, sonst feuert es bei jeder Kleinigkeit."""
|
||||
kurz = _job_kurz(_db_zeile())
|
||||
assert "meta" not in kurz
|
||||
assert "endTime" not in kurz
|
||||
|
||||
|
||||
def test_unveraenderliche_felder_erzeugen_keine_zusatz_ereignisse():
|
||||
"""`type`, `device` und `startTime` aendern sich ueber die Lebenszeit
|
||||
eines Jobs nie — sie kosten also nichts, obwohl sie mitfahren."""
|
||||
eins = _job_kurz(_db_zeile())
|
||||
zwei = _job_kurz(dict(_db_zeile(), progress=13))
|
||||
assert unterschiede({"j1": eins}, {"j1": zwei}) == [
|
||||
("job.progress", "j1", zwei)]
|
||||
|
||||
@@ -79,8 +79,38 @@ ENDE = ("completed", "failed", "canceled")
|
||||
|
||||
|
||||
def _job_kurz(zeile: dict) -> dict:
|
||||
"""Nur die Felder, deren Änderung ein Ereignis wert ist."""
|
||||
"""Nur die Felder, deren Änderung ein Ereignis wert ist.
|
||||
|
||||
## Warum `type` dazugehört (Befund des Commanders, 29.08.2026)
|
||||
|
||||
> „wenn man auf rippen starten klickt passiert irgendwas, was das Programm
|
||||
> nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es gibt
|
||||
> auch keine fehlermeldung."
|
||||
|
||||
Nachgestellt und in der Browser-Konsole gemessen:
|
||||
|
||||
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
|
||||
|
||||
Der Ablauf: Der Job startet in Wahrheit sehr wohl (gemessen: `processing`,
|
||||
12 %). Aber `job.created` trug nur `status`, `progress`, `title` und
|
||||
`error` — **keinen `type`**. Das UI fügt so einen halben Job in seine
|
||||
Liste ein, das Live-Log liest `job.type.toUpperCase()`, und weil es keine
|
||||
Fehlergrenze gab, riss der Fehler die GESAMTE Oberfläche mit. Leerer
|
||||
Bildschirm, keine Meldung.
|
||||
|
||||
Der Fehler ist damit an drei Stellen zugleich repariert: Das Ereignis
|
||||
trägt den Typ (hier), das UI verträgt sein Fehlen, und eine Fehlergrenze
|
||||
fängt den nächsten Fall dieser Art ab.
|
||||
|
||||
**Ein Ereignis „Job angelegt", das den halben Job weglässt, zwingt jeden
|
||||
Empfänger zum Raten.** `type` und `device` ändern sich über die Lebenszeit
|
||||
eines Jobs nie — sie kosten hier nichts und ersparen dem Empfänger die
|
||||
Frage, ob er gerade einen ganzen oder einen halben Job vor sich hat.
|
||||
"""
|
||||
return {
|
||||
"type": zeile.get("type"),
|
||||
"device": zeile.get("device"),
|
||||
"startTime": zeile.get("startTime"),
|
||||
"status": zeile.get("status"),
|
||||
"progress": zeile.get("progress"),
|
||||
"title": zeile.get("title"),
|
||||
@@ -88,6 +118,17 @@ def _job_kurz(zeile: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
#: Was ein Job-Ereignis mitbringen MUSS, damit die Oberflaeche ihn zeichnen
|
||||
#: kann, ohne zu raten. Gegengeprueft von test_waechter.py — es gibt kein
|
||||
#: gemeinsames Typsystem zwischen Python und dem UI, also steht der Vertrag
|
||||
#: hier und wird mechanisch bewacht.
|
||||
#:
|
||||
#: `startTime` steht dabei, weil ohne ihn in der Jobliste „Invalid Date"
|
||||
#: stand (am 29.08.2026 im Browser gesehen), bis Sekunden spaeter der
|
||||
#: naechste Schnappschuss kam.
|
||||
UI_PFLICHTFELDER = ("type", "startTime", "status", "progress")
|
||||
|
||||
|
||||
def unterschiede(vorher: dict, jetzt: dict) -> list:
|
||||
"""Was hat sich geändert? (pure Funktion — deshalb testbar ohne DB)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user