diff --git a/docker/ui/src/App.tsx b/docker/ui/src/App.tsx index 7d6be1f..3538e4a 100644 --- a/docker/ui/src/App.tsx +++ b/docker/ui/src/App.tsx @@ -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 */}
- {currentPage === 'dashboard' && } - {currentPage === 'anleitung' && } - {currentPage === 'logs' && } - {currentPage === 'settings' && } + {/* 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. */} + + {currentPage === 'dashboard' && } + {currentPage === 'anleitung' && } + {currentPage === 'logs' && } + {currentPage === 'settings' && } +
) @@ -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 ( - - - - - + + + + + + + ) } diff --git a/docker/ui/src/components/Fehlergrenze.tsx b/docker/ui/src/components/Fehlergrenze.tsx new file mode 100644 index 0000000..b7e91dc --- /dev/null +++ b/docker/ui/src/components/Fehlergrenze.tsx @@ -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 { + 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 ( +
+

+ {this.props.bereich + ? `${this.props.bereich} lässt sich gerade nicht anzeigen` + : 'Diese Ansicht lässt sich gerade nicht anzeigen'} +

+

+ Der Fehler betrifft nur die Anzeige.{' '} + Laufende Rips gehen weiter — Rippy arbeitet im + Hintergrund, auch wenn dieser Bereich leer bleibt. +

+

+ {this.state.fehler.message || String(this.state.fehler)} +

+
+ + +
+
+ ) + } +} diff --git a/docker/ui/src/components/LiveLogSection.tsx b/docker/ui/src/components/LiveLogSection.tsx index 5c5406d..10da539 100644 --- a/docker/ui/src/components/LiveLogSection.tsx +++ b/docker/ui/src/components/LiveLogSection.tsx @@ -45,7 +45,10 @@ export default function LiveLogSection() { Live-Log

{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'}

diff --git a/docker/ui/src/components/ui/Badge.tsx b/docker/ui/src/components/ui/Badge.tsx index 5028e28..66bf519 100644 --- a/docker/ui/src/components/ui/Badge.tsx +++ b/docker/ui/src/components/ui/Badge.tsx @@ -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 ( diff --git a/src/rippy/bus/test_waechter.py b/src/rippy/bus/test_waechter.py index 4548e60..1f6a769 100644 --- a/src/rippy/bus/test_waechter.py +++ b/src/rippy/bus/test_waechter.py @@ -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)] diff --git a/src/rippy/bus/waechter.py b/src/rippy/bus/waechter.py index fc5df79..652317a 100644 --- a/src/rippy/bus/waechter.py +++ b/src/rippy/bus/waechter.py @@ -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)