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 { ExternalLink, ScrollText, Settings } from "lucide-react"
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
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 }: {
|
* Handy-Menü „Mehr“: Hermes, Dienste, Einstellungen. Eigene Datei, damit es erst beim Öffnen lädt.
|
||||||
verbindung: ReactNode
|
* Die Verbindungsanzeige steht seit 09/2026 auf jeder Breite in der Kopfzeile, nicht mehr hier.
|
||||||
|
*/
|
||||||
|
export function MehrMenue({ onDienste, onEinstellungen, onSchliessen }: {
|
||||||
onDienste: () => void
|
onDienste: () => void
|
||||||
onEinstellungen: () => void
|
onEinstellungen: () => void
|
||||||
onSchliessen: () => void
|
onSchliessen: () => void
|
||||||
@@ -17,7 +18,6 @@ export function MehrMenue({ verbindung, onDienste, onEinstellungen, onSchliessen
|
|||||||
<SheetDescription className="sr-only">Weitere Bereiche</SheetDescription>
|
<SheetDescription className="sr-only">Weitere Bereiche</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex flex-col gap-2 px-4">
|
<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">
|
<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
|
<ExternalLink className="size-5" aria-hidden /> Hermes-Dashboard öffnen
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ const FARBE = {
|
|||||||
info: "border-cyan-rand bg-cyan-grund text-cyan-text",
|
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() {
|
export function Meldungen() {
|
||||||
const liste = useMeldungen()
|
const liste = useMeldungen()
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-live="polite"
|
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) => (
|
{liste.map((m) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
+20
-13
@@ -34,15 +34,23 @@ function Uhr() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** „Box online“ / „Getrennt“ — auf jeder Breite sichtbar, am Handy etwas kleiner. */
|
||||||
function Verbindung() {
|
function Verbindung() {
|
||||||
const lage = useStromLage()
|
const lage = useStromLage()
|
||||||
const live = lage === "live"
|
const live = lage === "live"
|
||||||
return (
|
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
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -69,10 +77,12 @@ export function Shell() {
|
|||||||
const zu = () => setSchublade(null)
|
const zu = () => setSchublade(null)
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
||||||
<header className="flex items-center justify-between gap-4 md:gap-6">
|
{/* 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 />
|
<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 }) => (
|
{NAV.map(({ to, label }) => (
|
||||||
<Link
|
<Link
|
||||||
key={to}
|
key={to}
|
||||||
@@ -85,11 +95,9 @@ export function Shell() {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="flex items-center gap-4 md:gap-5">
|
<div className="flex items-center gap-3 lg:gap-5">
|
||||||
<div className="hidden lg:block">
|
|
||||||
<Verbindung />
|
<Verbindung />
|
||||||
</div>
|
<div className="hidden items-center gap-1 lg:flex">
|
||||||
<div className="hidden items-center gap-1 md:flex">
|
|
||||||
<a
|
<a
|
||||||
href="/hermes-ui/"
|
href="/hermes-ui/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -127,10 +135,10 @@ export function Shell() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Handy: Leiste unten, mit Abstand zur Wisch-Leiste des Telefons. */}
|
{/* Handy und Tablet: Leiste unten, mit Abstand zur Wisch-Leiste des Telefons. */}
|
||||||
<nav
|
<nav
|
||||||
aria-label="Hauptnavigation"
|
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 }) => (
|
{NAV.map(({ to, label, Icon }) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -157,7 +165,6 @@ export function Shell() {
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{schublade === "mehr" && (
|
{schublade === "mehr" && (
|
||||||
<MehrMenue
|
<MehrMenue
|
||||||
verbindung={<Verbindung />}
|
|
||||||
onDienste={() => setSchublade("dienste")}
|
onDienste={() => setSchublade("dienste")}
|
||||||
onEinstellungen={() => setSchublade("einstellungen")}
|
onEinstellungen={() => setSchublade("einstellungen")}
|
||||||
onSchliessen={zu}
|
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
|
// Der Messpunkt liegt bewusst AUSSERHALB von React (useSyncExternalStore): So zeichnen
|
||||||
// nur die Instrumente jede Sekunde neu, nicht die ganze Seite.
|
// 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 { useEffect, useSyncExternalStore } from "react"
|
||||||
import { useQueryClient } from "@tanstack/react-query"
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
@@ -27,6 +34,11 @@ export interface MetrikPunkt {
|
|||||||
|
|
||||||
export type StromLage = "verbinde" | "live" | "getrennt"
|
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 lage: StromLage = "verbinde"
|
||||||
let letzte: MetrikPunkt | null = null
|
let letzte: MetrikPunkt | null = null
|
||||||
const hoerer = new Set<() => void>()
|
const hoerer = new Set<() => void>()
|
||||||
@@ -43,12 +55,6 @@ export const stromVerbunden = () => lage === "live"
|
|||||||
export const useStromLage = () => useSyncExternalStore(abonniere, () => lage)
|
export const useStromLage = () => useSyncExternalStore(abonniere, () => lage)
|
||||||
export const useLetzteMetrik = () => useSyncExternalStore(abonniere, () => letzte)
|
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) {
|
function setzeLage(neu: StromLage) {
|
||||||
if (neu !== lage) {
|
if (neu !== lage) {
|
||||||
lage = neu
|
lage = neu
|
||||||
@@ -59,14 +65,53 @@ function setzeLage(neu: StromLage) {
|
|||||||
export function useStrom() {
|
export function useStrom() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
useEffect(() => {
|
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")
|
const es = new EventSource("/api/stream")
|
||||||
|
quelle = es
|
||||||
es.onopen = () => {
|
es.onopen = () => {
|
||||||
|
pause = PAUSE_ERST_MS
|
||||||
setzeLage("live")
|
setzeLage("live")
|
||||||
|
lebt()
|
||||||
// Was während einer Trennung passiert ist, hat der Strom nicht gemeldet.
|
// Was während einer Trennung passiert ist, hat der Strom nicht gemeldet.
|
||||||
qc.invalidateQueries()
|
qc.invalidateQueries()
|
||||||
}
|
}
|
||||||
es.onerror = () => setzeLage("getrennt")
|
es.onerror = () => {
|
||||||
|
setzeLage("getrennt")
|
||||||
|
if (es.readyState === EventSource.CLOSED) {
|
||||||
|
window.clearTimeout(stilleTimer)
|
||||||
|
spaeterNeu()
|
||||||
|
}
|
||||||
|
}
|
||||||
es.addEventListener("invalidate", (e) => {
|
es.addEventListener("invalidate", (e) => {
|
||||||
|
lebt()
|
||||||
try {
|
try {
|
||||||
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
|
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
|
||||||
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
|
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
|
||||||
@@ -75,14 +120,36 @@ export function useStrom() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
es.addEventListener("metrik", (e) => {
|
es.addEventListener("metrik", (e) => {
|
||||||
|
lebt()
|
||||||
try {
|
try {
|
||||||
metrikEinspeisen(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
|
letzte = JSON.parse((e as MessageEvent).data) as MetrikPunkt
|
||||||
|
melde()
|
||||||
} catch {
|
} catch {
|
||||||
/* ein kaputter Messpunkt reißt den Strom nicht */
|
/* 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 () => {
|
return () => {
|
||||||
es.close()
|
beendet = true
|
||||||
|
window.clearTimeout(wiederTimer)
|
||||||
|
window.clearTimeout(stilleTimer)
|
||||||
|
window.removeEventListener("online", wiederDa)
|
||||||
|
document.removeEventListener("visibilitychange", wiederDa)
|
||||||
|
quelle?.close()
|
||||||
setzeLage("getrennt")
|
setzeLage("getrennt")
|
||||||
}
|
}
|
||||||
}, [qc])
|
}, [qc])
|
||||||
|
|||||||
Reference in New Issue
Block a user