feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53)
Die Abarbeitungsansicht fragte alle zehn Sekunden nach dem Zustand. Jetzt melden vier Datenbank-Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine LISTEN-Verbindung je Prozess hoert zu, und der Route Handler schiebt weiter. Gemessen: der Haken des Kollegen ist nach 485 ms beim Zuschauer (am Handy 487 ms), vorher bis zu zehn Sekunden. Zwanzig offene Laschen kosten nichts mehr statt zwanzig Abfragen je zehn Sekunden. Das Ereignis kommt aus der Datenbank und nicht aus dem Dienst, aus demselben Grund, aus dem auch die Regeln dort stehen: ein Trigger sieht auch den Seed, eine Migration und die Reparatur per psql. Und NOTIFY erreicht jede Instanz der Anwendung, ohne einen zweiten Dienst. Welche Tabellen melden, haengt an dem, was `laufZustand` liefert — nicht daran, was das Issue vorgeschlagen hat. Mit Triggern nur auf Schrittergebnis und Lauf waere der Strom ein **Rueckschritt** gewesen: das Polling verglich den ganzen Stand und sah deshalb auch Kommentare, ein Strom sieht nur, was gemeldet wird. Der Kommentar des Kollegen waere nie angekommen, und die Seite haette ausgesehen wie immer. Es melden jetzt vier Tabellen, und `laufZustand` traegt `anzahlAnhaenge` — ein Ereignis ohne Entsprechung im Zustand loest nichts aus. Das Polling bleibt als Rueckfall an **derselben** Adresse, unterschieden nur am Accept-Kopf: eine Rechtepruefung, ein Lesemodell, eine Nutzlast. Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand rechtErforderlich vergessen kann. Bei erreichter Obergrenze antwortet der Handler gewoehnlich mit JSON; EventSource meldet darauf einen Fehler und versucht es nicht wieder, also faellt der Client von sich aus zurueck — ohne Sonderfall. Nebenbei zwei Fehler in docs/betrieb/reverse-proxy.md behoben, beide aus #52: `client_max_body_size 8m` bei `ANHANG_MAX_MB=10` haette ein 10-MB-Foto mit 413 abgewiesen, bevor die eigene Meldung zum Zuge kommt. Closes #53 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3ccf3aa2d9
commit
1be73b266c
@@ -1,34 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Holt alle zehn Sekunden den Zustand des Laufs (#013).
|
||||
* Haelt die Abarbeitungsansicht aktuell (#013, Strom seit #033).
|
||||
*
|
||||
* Damit sehen zwei Browser am selben Lauf die Arbeit des anderen — der Fall aus
|
||||
* dem Auftrag: zwei Techniker teilen sich ein Onboarding.
|
||||
*
|
||||
* Vier Entscheidungen stecken darin:
|
||||
* **Seit #033 ueber einen Strom**, nicht mehr alle zehn Sekunden. Die Datenbank
|
||||
* meldet die Aenderung, der Server schiebt sie weiter, und der Haken des
|
||||
* Kollegen steht in unter einer Sekunde auf dem anderen Schirm statt nach bis zu
|
||||
* zehn. Das Polling bleibt als **Rueckfall** — an derselben Adresse, mit
|
||||
* derselben Nutzlast.
|
||||
*
|
||||
* Fuenf Entscheidungen stecken darin:
|
||||
*
|
||||
* 1. **Pause bei verstecktem Tab.** Ein Lauf bleibt oft stundenlang offen im
|
||||
* Hintergrund; ohne Pause waeren das 360 Abfragen je Stunde und Tab, fuer
|
||||
* niemanden.
|
||||
* Hintergrund. Der Strom wird dabei **geschlossen**, nicht nur ignoriert:
|
||||
* eine offene Verbindung je Hintergrundlasche ist genau das, was die
|
||||
* Obergrenze im Handler verhindern soll.
|
||||
* 2. **`router.refresh()` statt eigener Zustandsverwaltung.** Die Seite ist eine
|
||||
* Server-Komponente; ein Neuladen ihrer Daten ist genau das, was gebraucht
|
||||
* wird, und es gibt keine zweite Wahrheit im Browser. Aktualisiert wird nur,
|
||||
* wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite alle zehn
|
||||
* Sekunden neu rendern und dabei Eingaben verlieren, an denen jemand tippt.
|
||||
* wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite bei jedem
|
||||
* Ereignis neu rendern und dabei Eingaben verlieren, an denen jemand tippt.
|
||||
* 3. **Steht ein generiertes Passwort auf der Seite, wird nicht von selbst
|
||||
* neu geladen**, sondern gefragt. Das Passwort entsteht bei jedem Aufbau der
|
||||
* Seite neu; ein Neuladen im Hintergrund tauschte es dem Techniker zwischen
|
||||
* Lesen und Einfuegen aus. Die Antwort des Pollings traegt deshalb keine
|
||||
* Befehle (siehe `laufZustand`) — und diese Zusage waere nichts wert, wenn
|
||||
* das Polling stattdessen die ganze Seite neu bauen liesse.
|
||||
* Lesen und Einfuegen aus. Die Nutzlast traegt deshalb keine Befehle (siehe
|
||||
* `laufZustand`) — und diese Zusage waere nichts wert, wenn die
|
||||
* Aktualisierung stattdessen die ganze Seite neu bauen liesse.
|
||||
* 4. **Abbruch bei 401 und 404.** Eine abgelaufene Anmeldung oder ein
|
||||
* verschwundener Lauf sind kein Grund weiterzufragen.
|
||||
* 5. **Der Rueckfall wird einmal gemeldet, nicht bei jedem Versuch.** Sonst
|
||||
* schreibt eine Seite hinter einem puffernden Proxy im Minutentakt dieselbe
|
||||
* Zeile ins Protokoll der Konsole.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Was ein geaenderter Zustand ausloesen soll.
|
||||
*
|
||||
@@ -40,6 +51,22 @@ export function reaktionAufAenderung(passwortSichtbar: boolean): "neu-laden" | "
|
||||
return passwortSichtbar ? "fragen" : "neu-laden";
|
||||
}
|
||||
|
||||
/** `EventSource.CLOSED`, ohne im Test ein `EventSource` zu brauchen. */
|
||||
export const STROM_GESCHLOSSEN = 2;
|
||||
|
||||
/**
|
||||
* Ist ein Fehler am Strom das Ende – oder nur eine Unterbrechung?
|
||||
*
|
||||
* `EventSource` verbindet sich nach einem Abbruch **selbst** wieder; dann steht
|
||||
* `readyState` auf `CONNECTING`, und zurueckzufallen waere verfruehtes Aufgeben.
|
||||
* Endgueltig ist nur `CLOSED` — so antwortet der Browser etwa, wenn die Antwort
|
||||
* nicht `text/event-stream` ist. Genau das schickt der Handler bei erreichter
|
||||
* Obergrenze, und genau dann soll gepollt werden.
|
||||
*/
|
||||
export function sollZurueckfallen(readyState: number): boolean {
|
||||
return readyState === STROM_GESCHLOSSEN;
|
||||
}
|
||||
|
||||
export function LaufAktualisierung({
|
||||
laufId,
|
||||
passwortSichtbar = false,
|
||||
@@ -57,29 +84,46 @@ export function LaufAktualisierung({
|
||||
const letzterStand = useRef<string | null>(null);
|
||||
const [neues, setzeNeues] = useState(false);
|
||||
|
||||
/**
|
||||
* Ein gelesener Stand: vergleichen, und nur bei echter Aenderung reagieren.
|
||||
*
|
||||
* Dieselbe Stelle fuer Strom und Polling. Waere der Vergleich zweimal
|
||||
* geschrieben, koennten die beiden Wege unterschiedlich reagieren — und der
|
||||
* Rueckfall waere dann kein Rueckfall, sondern ein zweites Verhalten.
|
||||
*/
|
||||
const standVerarbeiten = useCallback(
|
||||
(stand: string): void => {
|
||||
if (letzterStand.current !== null && letzterStand.current !== stand) {
|
||||
if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true);
|
||||
else router.refresh();
|
||||
}
|
||||
letzterStand.current = stand;
|
||||
},
|
||||
[passwortSichtbar, router],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let abgebrochen = false;
|
||||
const adresse = `/api/laeufe/${laufId}/zustand`;
|
||||
let eingestellt = false;
|
||||
let quelle: EventSource | undefined;
|
||||
let zeitgeber: ReturnType<typeof setTimeout> | undefined;
|
||||
let polltJetzt = false;
|
||||
let rueckfallGemeldet = false;
|
||||
|
||||
/* ---------------- Polling: der Rueckfall ---------------- */
|
||||
|
||||
async function holen(): Promise<void> {
|
||||
if (abgebrochen || document.visibilityState === "hidden") {
|
||||
if (eingestellt || document.visibilityState === "hidden") {
|
||||
plane();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const antwort = await fetch(`/api/laeufe/${laufId}/zustand`, { cache: "no-store" });
|
||||
const antwort = await fetch(adresse, { cache: "no-store" });
|
||||
if (antwort.status === 401 || antwort.status === 404) {
|
||||
abgebrochen = true;
|
||||
eingestellt = true;
|
||||
return;
|
||||
}
|
||||
if (antwort.ok) {
|
||||
const stand = JSON.stringify(await antwort.json());
|
||||
if (letzterStand.current !== null && letzterStand.current !== stand) {
|
||||
if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true);
|
||||
else router.refresh();
|
||||
}
|
||||
letzterStand.current = stand;
|
||||
}
|
||||
if (antwort.ok) standVerarbeiten(JSON.stringify(await antwort.json()));
|
||||
} catch {
|
||||
// Netz weg, Rechner im Standby: einfach beim naechsten Mal wieder.
|
||||
}
|
||||
@@ -87,23 +131,99 @@ export function LaufAktualisierung({
|
||||
}
|
||||
|
||||
function plane(): void {
|
||||
if (abgebrochen) return;
|
||||
if (eingestellt || !polltJetzt) return;
|
||||
zeitgeber = setTimeout(() => void holen(), 10_000);
|
||||
}
|
||||
|
||||
void holen();
|
||||
// Kommt der Tab zurueck, sofort fragen statt bis zu zehn Sekunden zu warten.
|
||||
const beiSichtbarkeit = () => {
|
||||
if (document.visibilityState === "visible") void holen();
|
||||
function aufPollingUmstellen(grund: string): void {
|
||||
if (eingestellt || polltJetzt) return;
|
||||
polltJetzt = true;
|
||||
stromSchliessen();
|
||||
if (!rueckfallGemeldet) {
|
||||
rueckfallGemeldet = true;
|
||||
// Einmal, nicht bei jedem Versuch – siehe Kopfkommentar.
|
||||
console.info(`Live-Aktualisierung nicht moeglich (${grund}), es wird gepollt.`);
|
||||
}
|
||||
void holen();
|
||||
}
|
||||
|
||||
/* ---------------- Strom: der Normalfall ---------------- */
|
||||
|
||||
function stromSchliessen(): void {
|
||||
quelle?.close();
|
||||
quelle = undefined;
|
||||
}
|
||||
|
||||
function stromOeffnen(): void {
|
||||
if (eingestellt || polltJetzt || quelle || typeof EventSource === "undefined") return;
|
||||
const neu = new EventSource(adresse);
|
||||
quelle = neu;
|
||||
|
||||
neu.addEventListener("zustand", (ereignis) => {
|
||||
standVerarbeiten((ereignis as MessageEvent<string>).data);
|
||||
});
|
||||
|
||||
// Der Server sagt, dass es nichts mehr zu sehen gibt – nicht weiterfragen.
|
||||
neu.addEventListener("ende", () => {
|
||||
eingestellt = true;
|
||||
stromSchliessen();
|
||||
});
|
||||
|
||||
neu.addEventListener("error", () => {
|
||||
if (sollZurueckfallen(neu.readyState)) aufPollingUmstellen("Strom geschlossen");
|
||||
// Sonst: `EventSource` verbindet selbst wieder, hier ist nichts zu tun.
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- Sichtbarkeit ---------------- */
|
||||
|
||||
const beiSichtbarkeit = (): void => {
|
||||
if (eingestellt) return;
|
||||
if (document.visibilityState === "hidden") {
|
||||
// Verbindung wirklich freigeben, nicht bloss ignorieren.
|
||||
stromSchliessen();
|
||||
if (zeitgeber) clearTimeout(zeitgeber);
|
||||
return;
|
||||
}
|
||||
/*
|
||||
* Zurueck im Vordergrund: erst den Stand holen, dann wieder zuhoeren.
|
||||
* Waehrend die Lasche weg war, kam keine Meldung an — ohne diesen Abruf
|
||||
* stuende der alte Stand da, bis das naechste Mal jemand etwas aendert.
|
||||
*/
|
||||
if (polltJetzt) void holen();
|
||||
else {
|
||||
stromOeffnen();
|
||||
void einmalHolen();
|
||||
}
|
||||
};
|
||||
|
||||
/** Ein einzelner Abruf, ohne das Polling einzuschalten. */
|
||||
async function einmalHolen(): Promise<void> {
|
||||
try {
|
||||
const antwort = await fetch(adresse, { cache: "no-store" });
|
||||
if (antwort.status === 401 || antwort.status === 404) {
|
||||
eingestellt = true;
|
||||
stromSchliessen();
|
||||
return;
|
||||
}
|
||||
if (antwort.ok) standVerarbeiten(JSON.stringify(await antwort.json()));
|
||||
} catch {
|
||||
// Der Strom liefert den Stand ohnehin gleich mit.
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof EventSource === "undefined") aufPollingUmstellen("kein EventSource");
|
||||
else stromOeffnen();
|
||||
|
||||
document.addEventListener("visibilitychange", beiSichtbarkeit);
|
||||
|
||||
return () => {
|
||||
abgebrochen = true;
|
||||
eingestellt = true;
|
||||
stromSchliessen();
|
||||
if (zeitgeber) clearTimeout(zeitgeber);
|
||||
document.removeEventListener("visibilitychange", beiSichtbarkeit);
|
||||
};
|
||||
}, [laufId, router, passwortSichtbar]);
|
||||
}, [laufId, standVerarbeiten]);
|
||||
|
||||
// Ohne Passwort auf der Seite ist die Wirkung unsichtbar: sie erneuert sich.
|
||||
if (!neues) return null;
|
||||
|
||||
Reference in New Issue
Block a user