strom: Live-Strom baut sich selbst neu auf, Verbindungsanzeige auf jeder Breite
- Gibt EventSource endgueltig auf (502/503 vom Proxy, waehrend MC2 neu startet), baut die App den Strom selbst neu auf: Pause 2 s, 4 s, 8 s ... bis 30 s, sofort wenn Netz oder Tab zurueck sind. - Stille-Waechter: Der Strom schickt jede Sekunde einen Messpunkt. Kommt 20 s nichts, gilt die Leitung als tot (Handy im Standby, WLAN-Wechsel, Proxy haelt offen) und wird neu aufgebaut. Im Test mit einer Attrappe: vorher blieb "Box online" stehen. - "Box online / Getrennt" steht jetzt auf jeder Breite in der Kopfzeile (am Handy kleiner, getrennt in Bernstein), nicht mehr nur im Mehr-Menue. - Obere Navigation erst ab 1024 px, darunter die Leiste unten wie am Handy: Zwischen 768 und ~880 px lief die Kopfzeile schon vorher seitlich ueber, mit der Anzeige bis 1023. - metrikEinspeisen (nur fuer Tests gedacht, nie benutzt) entfaellt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
739f27a7e5
commit
634ad27302
@@ -0,0 +1,72 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { act, render, screen } from "@testing-library/react"
|
||||
import { STILLE_MS, useStrom, useStromLage } from "./strom"
|
||||
|
||||
/** EventSource-Attrappe: Der Test entscheidet, wann geöffnet wird und wann sie aufgibt. */
|
||||
class FalscheQuelle {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
static CLOSED = 2
|
||||
static alle: FalscheQuelle[] = []
|
||||
readyState = FalscheQuelle.CONNECTING
|
||||
onopen: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
constructor(public url: string) {
|
||||
FalscheQuelle.alle.push(this)
|
||||
}
|
||||
addEventListener() {}
|
||||
close() {
|
||||
this.readyState = FalscheQuelle.CLOSED
|
||||
}
|
||||
oeffnen() {
|
||||
this.readyState = FalscheQuelle.OPEN
|
||||
this.onopen?.()
|
||||
}
|
||||
aufgeben() {
|
||||
this.readyState = FalscheQuelle.CLOSED
|
||||
this.onerror?.()
|
||||
}
|
||||
}
|
||||
|
||||
function Anzeige() {
|
||||
useStrom()
|
||||
return <span>{useStromLage()}</span>
|
||||
}
|
||||
|
||||
describe("useStrom", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
FalscheQuelle.alle = []
|
||||
vi.stubGlobal("EventSource", FalscheQuelle)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it("baut den Strom selbst neu auf, wenn EventSource aufgibt oder die Leitung verstummt", () => {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<Anzeige />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
const erste = FalscheQuelle.alle[0]
|
||||
act(() => erste.oeffnen())
|
||||
expect(screen.getByText("live")).toBeInTheDocument()
|
||||
|
||||
// 503 vom Proxy, während MC2 neu startet: EventSource gibt für immer auf.
|
||||
act(() => erste.aufgeben())
|
||||
expect(screen.getByText("getrennt")).toBeInTheDocument()
|
||||
act(() => vi.advanceTimersByTime(2_000))
|
||||
expect(FalscheQuelle.alle).toHaveLength(2)
|
||||
|
||||
// Leitung offen, aber es kommt nichts mehr (Handy im Standby): nach STILLE_MS neu.
|
||||
const zweite = FalscheQuelle.alle[1]
|
||||
act(() => zweite.oeffnen())
|
||||
act(() => vi.advanceTimersByTime(STILLE_MS))
|
||||
expect(screen.getByText("getrennt")).toBeInTheDocument()
|
||||
expect(zweite.readyState).toBe(FalscheQuelle.CLOSED)
|
||||
act(() => vi.advanceTimersByTime(2_000))
|
||||
expect(FalscheQuelle.alle).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
+93
-26
@@ -7,6 +7,13 @@
|
||||
//
|
||||
// Der Messpunkt liegt bewusst AUSSERHALB von React (useSyncExternalStore): So zeichnen
|
||||
// nur die Instrumente jede Sekunde neu, nicht die ganze Seite.
|
||||
//
|
||||
// Wiederaufbau: Bei einem Netzfehler versucht der Browser es von selbst erneut. Antwortet der
|
||||
// Server aber mit einem Fehler (502/503 vom Proxy, während MC2 neu startet), gibt EventSource
|
||||
// für immer auf — dann bauen wir ihn selbst neu auf, mit wachsender Pause (2 s bis 30 s) und
|
||||
// sofort, wenn das Netz oder der Tab zurück ist. Dazu ein Stille-Wächter: Der Strom schickt jede
|
||||
// Sekunde einen Messpunkt. Kommt 20 s lang nichts, ist die Leitung tot, ohne dass der Browser es
|
||||
// merkt (Handy im Standby, WLAN gewechselt, Proxy hält die Verbindung offen) — auch dann neu.
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
@@ -27,6 +34,11 @@ export interface MetrikPunkt {
|
||||
|
||||
export type StromLage = "verbinde" | "live" | "getrennt"
|
||||
|
||||
const PAUSE_ERST_MS = 2_000
|
||||
const PAUSE_HOECHST_MS = 30_000
|
||||
/** So lange darf der Strom schweigen, bevor er als tot gilt (er schickt jede Sekunde einen Messpunkt). */
|
||||
export const STILLE_MS = 20_000
|
||||
|
||||
let lage: StromLage = "verbinde"
|
||||
let letzte: MetrikPunkt | null = null
|
||||
const hoerer = new Set<() => void>()
|
||||
@@ -43,12 +55,6 @@ export const stromVerbunden = () => lage === "live"
|
||||
export const useStromLage = () => useSyncExternalStore(abonniere, () => lage)
|
||||
export const useLetzteMetrik = () => useSyncExternalStore(abonniere, () => letzte)
|
||||
|
||||
/** Nur für Tests: Messpunkt direkt einspeisen. */
|
||||
export function metrikEinspeisen(m: MetrikPunkt | null) {
|
||||
letzte = m
|
||||
melde()
|
||||
}
|
||||
|
||||
function setzeLage(neu: StromLage) {
|
||||
if (neu !== lage) {
|
||||
lage = neu
|
||||
@@ -59,30 +65,91 @@ function setzeLage(neu: StromLage) {
|
||||
export function useStrom() {
|
||||
const qc = useQueryClient()
|
||||
useEffect(() => {
|
||||
const es = new EventSource("/api/stream")
|
||||
es.onopen = () => {
|
||||
setzeLage("live")
|
||||
// Was während einer Trennung passiert ist, hat der Strom nicht gemeldet.
|
||||
qc.invalidateQueries()
|
||||
let quelle: EventSource | null = null
|
||||
let wiederTimer: number | undefined
|
||||
let stilleTimer: number | undefined
|
||||
let letzteRegung = Date.now()
|
||||
let pause = PAUSE_ERST_MS
|
||||
let beendet = false
|
||||
|
||||
function spaeterNeu() {
|
||||
if (beendet) return
|
||||
window.clearTimeout(wiederTimer)
|
||||
wiederTimer = window.setTimeout(verbinden, pause)
|
||||
pause = Math.min(pause * 2, PAUSE_HOECHST_MS)
|
||||
}
|
||||
es.onerror = () => setzeLage("getrennt")
|
||||
es.addEventListener("invalidate", (e) => {
|
||||
try {
|
||||
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
|
||||
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
|
||||
} catch {
|
||||
/* ein kaputtes Ereignis reißt den Strom nicht */
|
||||
|
||||
// Jedes Ereignis beweist, dass die Leitung lebt; bleibt es still, gilt sie als tot.
|
||||
function lebt() {
|
||||
letzteRegung = Date.now()
|
||||
window.clearTimeout(stilleTimer)
|
||||
stilleTimer = window.setTimeout(() => {
|
||||
quelle?.close()
|
||||
setzeLage("getrennt")
|
||||
spaeterNeu()
|
||||
}, STILLE_MS)
|
||||
}
|
||||
|
||||
function verbinden() {
|
||||
window.clearTimeout(wiederTimer)
|
||||
window.clearTimeout(stilleTimer)
|
||||
quelle?.close()
|
||||
const es = new EventSource("/api/stream")
|
||||
quelle = es
|
||||
es.onopen = () => {
|
||||
pause = PAUSE_ERST_MS
|
||||
setzeLage("live")
|
||||
lebt()
|
||||
// Was während einer Trennung passiert ist, hat der Strom nicht gemeldet.
|
||||
qc.invalidateQueries()
|
||||
}
|
||||
})
|
||||
es.addEventListener("metrik", (e) => {
|
||||
try {
|
||||
metrikEinspeisen(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
|
||||
} catch {
|
||||
/* ein kaputter Messpunkt reißt den Strom nicht */
|
||||
es.onerror = () => {
|
||||
setzeLage("getrennt")
|
||||
if (es.readyState === EventSource.CLOSED) {
|
||||
window.clearTimeout(stilleTimer)
|
||||
spaeterNeu()
|
||||
}
|
||||
}
|
||||
})
|
||||
es.addEventListener("invalidate", (e) => {
|
||||
lebt()
|
||||
try {
|
||||
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
|
||||
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
|
||||
} catch {
|
||||
/* ein kaputtes Ereignis reißt den Strom nicht */
|
||||
}
|
||||
})
|
||||
es.addEventListener("metrik", (e) => {
|
||||
lebt()
|
||||
try {
|
||||
letzte = JSON.parse((e as MessageEvent).data) as MetrikPunkt
|
||||
melde()
|
||||
} catch {
|
||||
/* ein kaputter Messpunkt reißt den Strom nicht */
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Netz wieder da oder Tab wieder vorn (Handys kappen Verbindungen im Hintergrund): nicht auf
|
||||
// Pause oder Stille-Wächter warten, wenn der Strom aufgegeben hat oder seit Sekunden schweigt.
|
||||
const wiederDa = () => {
|
||||
if (beendet || document.visibilityState !== "visible") return
|
||||
if (quelle?.readyState === EventSource.CLOSED || Date.now() - letzteRegung > 5_000) {
|
||||
pause = PAUSE_ERST_MS
|
||||
verbinden()
|
||||
}
|
||||
}
|
||||
|
||||
verbinden()
|
||||
window.addEventListener("online", wiederDa)
|
||||
document.addEventListener("visibilitychange", wiederDa)
|
||||
return () => {
|
||||
es.close()
|
||||
beendet = true
|
||||
window.clearTimeout(wiederTimer)
|
||||
window.clearTimeout(stilleTimer)
|
||||
window.removeEventListener("online", wiederDa)
|
||||
document.removeEventListener("visibilitychange", wiederDa)
|
||||
quelle?.close()
|
||||
setzeLage("getrennt")
|
||||
}
|
||||
}, [qc])
|
||||
|
||||
Reference in New Issue
Block a user