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
@@ -1,10 +1,11 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { ExternalLink, ScrollText, Settings } from "lucide-react"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
|
||||
/** Handy-Menü „Mehr“: Hermes, Dienste, Einstellungen. Eigene Datei, damit es erst beim Öffnen lädt. */
|
||||
export function MehrMenue({ verbindung, onDienste, onEinstellungen, onSchliessen }: {
|
||||
verbindung: ReactNode
|
||||
/**
|
||||
* Handy-Menü „Mehr“: Hermes, Dienste, Einstellungen. Eigene Datei, damit es erst beim Öffnen lädt.
|
||||
* Die Verbindungsanzeige steht seit 09/2026 auf jeder Breite in der Kopfzeile, nicht mehr hier.
|
||||
*/
|
||||
export function MehrMenue({ onDienste, onEinstellungen, onSchliessen }: {
|
||||
onDienste: () => void
|
||||
onEinstellungen: () => void
|
||||
onSchliessen: () => void
|
||||
@@ -17,7 +18,6 @@ export function MehrMenue({ verbindung, onDienste, onEinstellungen, onSchliessen
|
||||
<SheetDescription className="sr-only">Weitere Bereiche</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-2 px-4">
|
||||
{verbindung}
|
||||
<a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base">
|
||||
<ExternalLink className="size-5" aria-hidden /> Hermes-Dashboard öffnen
|
||||
</a>
|
||||
|
||||
@@ -8,13 +8,13 @@ const FARBE = {
|
||||
info: "border-cyan-rand bg-cyan-grund text-cyan-text",
|
||||
}
|
||||
|
||||
/** Rückmeldungen unten rechts (am Handy über der Tab-Leiste), für Screenreader angesagt. */
|
||||
/** Rückmeldungen unten rechts (unter 1024 px über der Tab-Leiste), für Screenreader angesagt. */
|
||||
export function Meldungen() {
|
||||
const liste = useMeldungen()
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="pointer-events-none fixed right-4 bottom-24 left-4 z-50 flex flex-col items-end gap-2 md:bottom-6 md:left-auto md:w-[420px]"
|
||||
className="pointer-events-none fixed right-4 bottom-24 left-4 z-50 flex flex-col items-end gap-2 md:left-auto md:w-[420px] lg:bottom-6"
|
||||
>
|
||||
{liste.map((m) => (
|
||||
<div
|
||||
|
||||
+20
-13
@@ -34,15 +34,23 @@ function Uhr() {
|
||||
)
|
||||
}
|
||||
|
||||
/** „Box online“ / „Getrennt“ — auf jeder Breite sichtbar, am Handy etwas kleiner. */
|
||||
function Verbindung() {
|
||||
const lage = useStromLage()
|
||||
const live = lage === "live"
|
||||
return (
|
||||
<div className="schild flex items-center gap-2 text-[15px] text-text-3" aria-live="polite">
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
"schild flex shrink-0 items-center gap-1.5 text-[13px] tracking-[0.1em] sm:gap-2 sm:text-[15px] sm:tracking-[0.16em]",
|
||||
live ? "text-text-3" : "text-bernstein",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn("size-2 rounded-full", live ? "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]" : "bg-bernstein")}
|
||||
aria-hidden
|
||||
className={cn("size-2 shrink-0 rounded-full", live ? "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]" : "bg-bernstein")}
|
||||
/>
|
||||
<span>{live ? "Box online" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
|
||||
<span className="whitespace-nowrap">{live ? "Box online" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -69,10 +77,12 @@ export function Shell() {
|
||||
const zu = () => setSchublade(null)
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-dvh w-full max-w-[1440px] flex-col gap-5 px-4 pt-4 pb-28 sm:px-6 md:gap-6 md:px-10 md:pt-8 md:pb-10">
|
||||
<header className="flex items-center justify-between gap-4 md:gap-6">
|
||||
<div className="mx-auto flex min-h-dvh w-full max-w-[1440px] flex-col gap-5 px-4 pt-4 pb-28 sm:px-6 md:gap-6 md:px-10 md:pt-8 lg:pb-10">
|
||||
{/* Obere Navigation erst ab 1024 px: darunter passen Logo, Navigation, drei Knöpfe, Verbindung
|
||||
und Uhr nicht nebeneinander — Tablets bekommen die Leiste unten wie das Handy. */}
|
||||
<header className="flex items-center justify-between gap-4 lg:gap-6">
|
||||
<Marke />
|
||||
<nav aria-label="Hauptnavigation" className="hidden gap-1 rounded-xl border border-linie bg-[#121518] p-1 md:flex">
|
||||
<nav aria-label="Hauptnavigation" className="hidden gap-1 rounded-xl border border-linie bg-[#121518] p-1 lg:flex">
|
||||
{NAV.map(({ to, label }) => (
|
||||
<Link
|
||||
key={to}
|
||||
@@ -85,11 +95,9 @@ export function Shell() {
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex items-center gap-4 md:gap-5">
|
||||
<div className="hidden lg:block">
|
||||
<div className="flex items-center gap-3 lg:gap-5">
|
||||
<Verbindung />
|
||||
</div>
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
<div className="hidden items-center gap-1 lg:flex">
|
||||
<a
|
||||
href="/hermes-ui/"
|
||||
target="_blank"
|
||||
@@ -127,10 +135,10 @@ export function Shell() {
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Handy: Leiste unten, mit Abstand zur Wisch-Leiste des Telefons. */}
|
||||
{/* Handy und Tablet: Leiste unten, mit Abstand zur Wisch-Leiste des Telefons. */}
|
||||
<nav
|
||||
aria-label="Hauptnavigation"
|
||||
className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 gap-1 border-t border-linie bg-[#0e1119]/95 px-2 pt-2 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur md:hidden"
|
||||
className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 gap-1 border-t border-linie bg-[#0e1119]/95 px-2 pt-2 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur lg:hidden"
|
||||
>
|
||||
{NAV.map(({ to, label, Icon }) => (
|
||||
<Link
|
||||
@@ -157,7 +165,6 @@ export function Shell() {
|
||||
<Suspense fallback={null}>
|
||||
{schublade === "mehr" && (
|
||||
<MehrMenue
|
||||
verbindung={<Verbindung />}
|
||||
onDienste={() => setSchublade("dienste")}
|
||||
onEinstellungen={() => setSchublade("einstellungen")}
|
||||
onSchliessen={zu}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,14 +65,53 @@ function setzeLage(neu: StromLage) {
|
||||
export function useStrom() {
|
||||
const qc = useQueryClient()
|
||||
useEffect(() => {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.onerror = () => setzeLage("getrennt")
|
||||
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] })
|
||||
@@ -75,14 +120,36 @@ export function useStrom() {
|
||||
}
|
||||
})
|
||||
es.addEventListener("metrik", (e) => {
|
||||
lebt()
|
||||
try {
|
||||
metrikEinspeisen(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
|
||||
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