feat(ui): V2-3 (Teil 2) — das UI haengt am Ereignis-Strom, Taktgeber raus
Ampel / ampel (push) Successful in 47s

WAS: EventStreamProvider haelt EINE SSE-Verbindung fuer die ganze
Anwendung. Dashboard, Log-Kasten, Log-Seite, Laufwerksliste und
Worker-Liste beziehen ihren Zustand daraus. Sieben von neun setInterval
sind weg.

GEMESSEN AN DEN TAKTGEBERN, je offenem Tab:
  Dashboard      5 Endpunkte / 4 s   75/min  ->  0
  Log-Kasten     2 Endpunkte / 5 s   24/min  ->  0
  Laufwerke      1 Endpunkt  / 5 s   12/min  ->  0
  Log-Seite      1 Endpunkt  /10 s    6/min  ->  0 (+1 Abruf beim Oeffnen)
  Worker-Liste   1 Endpunkt  /15 s    4/min  ->  0 (+1 Abruf beim Oeffnen)
                                     ------
                                     121/min ->  ~2 einmalige Abrufe

Zwei Taktgeber bleiben bewusst: FirstRunWizard (laeuft nur VOR der
Einrichtung) und RipTargetModal (nur solange der Dialog offen ist).

DIE REGEL IST UMGEZOGEN, NICHT VERSCHWUNDEN: Ein Abriss ist keine
Aussage ueber die Welt. Der Provider BEHAELT bei einem Fehler den letzten
Stand und setzt nur `verbunden` auf false; es wird nie eine Liste geleert.
Jede Komponente uebernimmt einen Wert nur, wenn er wirklich da ist —
`devices === null` heisst "konnte nicht nachsehen", nicht "keine
Laufwerke". Das war der Fehler hinter "wird oft neu geladen".

EIN PLACEBO WENIGER: Oben rechts stand ein fest verdrahtetes "ONLINE" mit
pulsierendem Punkt — es leuchtete gruen, auch wenn die API tot war. Jetzt
zeigt es LIVE oder VERBINDUNG WEG, und im Tooltip steht, wann die letzte
Meldung kam.

DER SERVER SCHIEBT JETZT AUCH DEN SERVER-ZUSTAND: Neuer Ereignistyp
system.status (Hardware, Worker, Ablageziele) im 15-Sekunden-Takt des
Waechters — EINMAL im Server statt 15/min je Tab. Nur mitgeschickte
Schluessel werden uebernommen; ein fehlender heisst "behalte deinen Stand".

DAZU EIN FORMATFEHLER GEFUNDEN UND BEHOBEN: /logs bildet ts -> timestamp
ab, mein Snapshot lieferte die rohe DB-Zeile. Das UI haette "Invalid Date"
gezeigt — und zwar NUR im Live-Betrieb, nicht beim manuellen Neuladen.
Jetzt gibt es _log_zeile() einmal, benutzt von beiden.

UND EINEN ZWEITEN: system_lesen lief per asyncio.get_event_loop() in einem
Worker-Thread — dort ist das NICHT die laufende Schleife. Die Coroutine
waere nie gelaufen. Die Schleife wird jetzt im Startup festgehalten.

GEMESSEN: ruff sauber, 378 Tests + 3 uebersprungen, `npm run build`
durch (1650 Module). Der Live-Beweis steht noch aus — er kommt mit dem
Deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 09:32:50 +02:00
co-authored by Claude Opus 5
parent f097a0b59d
commit 95705c8d88
12 changed files with 656 additions and 125 deletions
+96 -12
View File
@@ -20,6 +20,7 @@ import mounts as mount_verwaltung
from rippy.core import notify
from rippy.bus import schema as bus_schema
from rippy.bus.memory import bus as ereignis_bus
from rippy.bus.waechter import Waechter
import phasen
import presets as preset_auswahl
import rohdaten
@@ -53,6 +54,12 @@ app = FastAPI(
)
# Der Ereignis-Waechter dieses Prozesses. Im Startup gesetzt; von
# /health/vorraete abgefragt, damit ein still gestorbener Waechter
# sichtbar ist statt nur als "es passiert nichts mehr".
ereignis_waechter = None
@app.on_event("startup")
async def startup_event():
"""Initialisiere Cache + Datenbank, validiere Konfiguration, starte Disc-Wache."""
@@ -64,6 +71,55 @@ async def startup_event():
except ConfigValidationError as e:
print(f"⚠️ Konfigurations-Warnung: {e}")
# Ereignis-Waechter (V2-3): macht Aenderungen des Workers zu Ereignissen.
# Der Worker ist ein eigener Container — ohne diese Bruecke wuesste die API
# nichts von seinem Fortschritt, und der SSE-Strom bliebe nach dem ersten
# Snapshot stumm. Warum ueber die Datenbank statt per Rueckruf oder Redis:
# siehe den Modul-Docstring von rippy/bus/waechter.py.
global ereignis_waechter
async def system_stand():
"""Server-Zustand fuers Dashboard — im langsamen Takt des Waechters.
Ohne das haette das Dashboard weiterhin einen eigenen 12-Sekunden-Takt
fuer /system/info, /capabilities und /storage-targets gebraucht — also
je offenem Tab 15 Anfragen pro Minute. So laeuft es EINMAL im Server,
egal wie viele Tabs offen sind.
"""
info, caps, ablagen = await asyncio.gather(
system_info(), capabilities(), storage_targets(),
return_exceptions=True,
)
stand = {}
if not isinstance(info, Exception):
stand["info"] = info
if not isinstance(caps, Exception):
stand["workers"] = (caps or {}).get("workers") or []
if not isinstance(ablagen, Exception):
stand["ablagen"] = ablagen
# Nur schicken, was wirklich gelesen wurde. Ein fehlender Schluessel
# heisst fuer das UI „behalte deinen Stand" — ein leerer Wert hiesse
# „es gibt nichts", und das waere wieder eine falsche Aussage.
return stand or None
# Die Ereignisschleife HIER festhalten. `einmal()` des Waechters laeuft in
# einem Worker-Thread (asyncio.to_thread) — dort gaebe
# `asyncio.get_event_loop()` nicht diese Schleife zurueck, sondern
# erzeugte eine neue oder wuerfe. Die Coroutine liefe dann nie.
schleife = asyncio.get_running_loop()
def system_stand_sync():
return asyncio.run_coroutine_threadsafe(system_stand(), schleife).result(timeout=30)
ereignis_waechter = Waechter(
db, ereignis_bus,
laufwerke_lesen=lambda: [
device_discovery.device_info(p)
for p in device_discovery.list_optical_devices()
],
system_lesen=system_stand_sync,
)
asyncio.create_task(ereignis_waechter.schleife())
# Gespeicherte Netzwerk-Speicherziele wiederherstellen — NICHT-BLOCKIEREND.
# Ein zickiger/langsamer Netz-Mount (der CIFS-Schreibtest in mounten() kann im
# Kernel haengen, wait_for_response) darf den API-Start NIE blockieren. Vorfall
@@ -370,7 +426,7 @@ async def _snapshot() -> dict:
return {
"jobs": [_job_row_to_model(z).model_dump() for z in db.list_jobs(limit=50)],
"workers": db.list_workers(),
"logs": db.list_logs(limit=50),
"logs": [_log_zeile(z) for z in db.list_logs(limit=50)],
}
zustand = await asyncio.to_thread(sammeln)
@@ -403,10 +459,18 @@ def _sse_rahmen(ereignis: dict) -> str:
return "\n".join(zeilen)
@app.get("/api/v2/events")
@app.get("/events")
async def events(request: Request, last_event_id: str = None):
"""Live-Strom: erst ein Snapshot, danach nur noch Änderungen.
Pfad bewusst `/events` und nicht `/api/v2/events`: Der nginx im UI-Container
entfernt das Präfix `/api/` (siehe ui/nginx.conf), und alle bestehenden
Routen hier sind entsprechend unpräfigiert (`/jobs`, `/devices`). Aus dem
Browser heißt der Aufruf damit `/api/events`. Die Versionierung `/api/v2/*`
aus KONZEPT-V2.md § 6.1 kommt, wenn die Routen in Router aufgeteilt werden —
sie jetzt für eine einzige Route einzuführen, hätte zwei Konventionen
nebeneinander bedeutet.
Der Browser schickt beim Wiederverbinden von selbst `Last-Event-ID` mit.
Passt die Lücke in den Ringpuffer, wird sie nachgeliefert; passt sie nicht,
kommt ein neuer Snapshot — AUSDRÜCKLICH, nicht stillschweigend. Ein UI, das
@@ -523,6 +587,17 @@ async def health_vorraete():
"stand": _MOUNT_STAND,
"intervall": MOUNT_WACHE_INTERVALL_SEKUNDEN,
},
# Der Ereignis-Waechter (V2-3) ist die Bruecke zwischen Worker und
# SSE-Strom. Stirbt er still, steht das UI — und zwar OHNE Fehlermeldung,
# weil ein Abriss dort bewusst als "nichts Neues" gilt und nicht als
# "nichts da". Deshalb muss sein Alter von aussen abfragbar sein.
"ereignisse": {
"alter_sekunden": None if ereignis_waechter is None
else round(ereignis_waechter.lebt_seit_sekunden(), 1),
"gesund": bool(ereignis_waechter and ereignis_waechter.gesund),
"abonnenten": len(getattr(ereignis_bus, "_abonnenten", ())),
"letzte_seq": ereignis_bus.seq,
},
}
@@ -2209,20 +2284,29 @@ async def setup_complete():
return {"done": True}
def _log_zeile(z: dict) -> dict:
"""DB-Zeile -> UI-Form. EINE Abbildung, zwei Aufrufer.
Sie stand bis V2-3 nur im /logs-Endpunkt. Der Snapshot des SSE-Stroms
haette daneben die rohen DB-Zeilen geliefert — mit `ts` statt `timestamp`
und einer Zahl statt eines Strings als id. Das UI haette „Invalid Date"
angezeigt, und zwar NUR im Live-Betrieb, nicht beim manuellen Neuladen:
genau die Sorte Fehler, die man lange sucht.
"""
return {
"id": str(z["id"]),
"timestamp": z["ts"].isoformat() if z.get("ts") else "",
"level": z.get("level") or "info",
"source": z.get("source") or "system",
"message": z.get("message") or "",
}
@app.get("/logs")
async def get_logs(limit: int = 200):
"""Echte Ereignisse aus der Datenbank (Watcher, API, Worker)."""
zeilen = await asyncio.to_thread(db.list_logs, min(limit, 1000))
return [
{
"id": str(z["id"]),
"timestamp": z["ts"].isoformat() if z.get("ts") else "",
"level": z.get("level") or "info",
"source": z.get("source") or "system",
"message": z.get("message") or "",
}
for z in zeilen
]
return [_log_zeile(z) for z in zeilen]
@app.get("/settings")
+1 -1
View File
@@ -257,7 +257,7 @@ def test_events_route_ist_verdrahtet():
Neues, ohne Fehlermeldung. Deshalb hier festgenagelt."""
from main import app
assert "/api/v2/events" in {route.path for route in app.routes}
assert "/events" in {route.path for route in app.routes}
def test_sse_rahmen_hat_das_format_das_der_browser_erwartet():