From 0f2fba4a629e6f7ec189d8656fe015ffafb1aff2 Mon Sep 17 00:00:00 2001 From: noroot Date: Fri, 11 Sep 2026 11:51:38 +0200 Subject: [PATCH] fix(ui): Dashboard riss bei SPA-Navigation die ganze Oberflaeche MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: Klick auf einen Link und zurueck aufs Dashboard → "EdgeGuard konnte nicht laden / TypeError: Cannot read properties of undefined (reading 'length')". Nach F5 ging es wieder, bis man erneut navigierte. Ursache ist ein Cache-Key-Konflikt. Unter ['haproxy','stats'] lagen zwei unvereinbare Formate: - Dashboard cachte { backends, frontends, error } (es zeigt auch Frontends an), - Domains, Domains/Detail, Backends, Backends/Detail und RoutingRules cachten via listHAProxyStats nur das Backend-ARRAY. Wer zuletzt lud, bestimmte die Form im Cache. Nach einem Besuch einer dieser Seiten bekam das Dashboard bei der Rueckkehr das Array serviert, stats.frontends war undefined und der Throw landete in der ErrorBoundary. Ein Reload half nur, weil er den Cache leert und das Dashboard wieder selbst befuellt. Fix: alle sechs Stellen cachen jetzt die vollstaendige Antwort; die fuenf Seiten, die nur die Backends brauchen, reduzieren per `select`. Damit gibt es unter dem Key genau eine Form, egal wer zuerst laedt. Zusaetzlich im Dashboard defensive Guards (`?? []`) auf data.vips, stats.frontends und stats.backends. Ein unerwartetes Format darf eine einzelne Karte kosten, aber nie die komplette Oberflaeche. Co-Authored-By: Claude Opus 5 --- management-ui/src/pages/Backends/Detail.tsx | 27 +++++++++++++++---- management-ui/src/pages/Backends/index.tsx | 27 +++++++++++++++---- management-ui/src/pages/Dashboard/index.tsx | 10 +++---- management-ui/src/pages/Domains/Detail.tsx | 27 +++++++++++++++---- management-ui/src/pages/Domains/index.tsx | 27 +++++++++++++++---- .../src/pages/RoutingRules/index.tsx | 27 +++++++++++++++---- 6 files changed, 115 insertions(+), 30 deletions(-) diff --git a/management-ui/src/pages/Backends/Detail.tsx b/management-ui/src/pages/Backends/Detail.tsx index 2ddf9dd..e8d1157 100644 --- a/management-ui/src/pages/Backends/Detail.tsx +++ b/management-ui/src/pages/Backends/Detail.tsx @@ -74,12 +74,28 @@ interface HAProxyStat { req_tot: number; req_rate: number last_change_sec: number; health: string } -async function listHAProxyStats(): Promise { +// Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt, +// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier +// IMMER die vollstaendige Antwort cachen und erst per `select` auf die +// Backends reduzieren, die diese Seite braucht. +// +// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing +// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag — +// nach einem Wechsel hierher und zurueck riss das Dashboard mit +// "Cannot read properties of undefined (reading 'length')" die ganze +// Oberflaeche in die ErrorBoundary. +interface HAProxyStatsPayload { + backends: HAProxyStat[] + frontends: unknown[] + error?: string +} +async function fetchHAProxyStats(): Promise { try { const r = await apiClient.get('/haproxy/stats') - if (!isEnvelope(r.data)) return [] - return (r.data.data as { backends?: HAProxyStat[] }).backends ?? [] - } catch { return [] } + if (!isEnvelope(r.data)) return { backends: [], frontends: [] } + const d = r.data.data as Partial + return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error } + } catch { return { backends: [], frontends: [] } } } function fmtBytes(n: number): string { @@ -112,7 +128,8 @@ export default function BackendDetailPage() { const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains }) const { data: haproxyStats } = useQuery({ queryKey: ['haproxy', 'stats'], - queryFn: listHAProxyStats, + queryFn: fetchHAProxyStats, + select: (d: HAProxyStatsPayload) => d.backends, refetchInterval: 10_000, }) const [form] = Form.useForm() diff --git a/management-ui/src/pages/Backends/index.tsx b/management-ui/src/pages/Backends/index.tsx index 1416dd9..e923658 100644 --- a/management-ui/src/pages/Backends/index.tsx +++ b/management-ui/src/pages/Backends/index.tsx @@ -118,12 +118,28 @@ function fmtBytes(n: number): string { if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB' return n + ' B' } -async function listHAProxyStats(): Promise { +// Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt, +// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier +// IMMER die vollstaendige Antwort cachen und erst per `select` auf die +// Backends reduzieren, die diese Seite braucht. +// +// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing +// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag — +// nach einem Wechsel hierher und zurueck riss das Dashboard mit +// "Cannot read properties of undefined (reading 'length')" die ganze +// Oberflaeche in die ErrorBoundary. +interface HAProxyStatsPayload { + backends: HAProxyStat[] + frontends: unknown[] + error?: string +} +async function fetchHAProxyStats(): Promise { try { const r = await apiClient.get('/haproxy/stats') - if (!isEnvelope(r.data)) return [] - return (r.data.data as { backends?: HAProxyStat[] }).backends ?? [] - } catch { return [] } + if (!isEnvelope(r.data)) return { backends: [], frontends: [] } + const d = r.data.data as Partial + return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error } + } catch { return { backends: [], frontends: [] } } } export default function BackendsPage() { @@ -146,7 +162,8 @@ export default function BackendsPage() { const haproxyService = services?.find(s => s.unit === 'haproxy.service' || s.unit === 'haproxy') const { data: haproxyStats } = useQuery({ queryKey: ['haproxy', 'stats'], - queryFn: listHAProxyStats, + queryFn: fetchHAProxyStats, + select: (d: HAProxyStatsPayload) => d.backends, refetchInterval: 15_000, }) diff --git a/management-ui/src/pages/Dashboard/index.tsx b/management-ui/src/pages/Dashboard/index.tsx index 2e0da67..fb1075c 100644 --- a/management-ui/src/pages/Dashboard/index.tsx +++ b/management-ui/src/pages/Dashboard/index.tsx @@ -613,11 +613,11 @@ function VIPCard({ data }: { data?: VIPStatus | null }) { > {!data ? ( - ) : data.vips.length === 0 ? ( + ) : (data.vips ?? []).length === 0 ? ( {t('dashboard.vipCard.noVips')} ) : ( - {data.vips.map((v) => ( + {(data.vips ?? []).map((v) => (
{t('dashboard.haproxyCard.title')}} extra={ - stats.frontends.length > 0 && ( + (stats.frontends ?? []).length > 0 && ( {totalSessions} sess {totalReqRate > 0 && {totalReqRate}/s} @@ -731,7 +731,7 @@ function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) { )} {/* Listeners */} - {stats.frontends.length > 0 && ( + {(stats.frontends ?? []).length > 0 && ( <> {t('dashboard.haproxyCard.frontends')} @@ -752,7 +752,7 @@ function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) { )} {/* Backends */} - {stats.backends.length === 0 && !stats.error ? ( + {(stats.backends ?? []).length === 0 && !stats.error ? ( {t('dashboard.haproxyCard.empty')} ) : ( <> diff --git a/management-ui/src/pages/Domains/Detail.tsx b/management-ui/src/pages/Domains/Detail.tsx index 49fe998..ebc7e6b 100644 --- a/management-ui/src/pages/Domains/Detail.tsx +++ b/management-ui/src/pages/Domains/Detail.tsx @@ -101,12 +101,28 @@ async function listCerts(): Promise { if (!isEnvelope(r.data)) return [] return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? [] } -async function listHAProxyStats(): Promise { +// Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt, +// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier +// IMMER die vollstaendige Antwort cachen und erst per `select` auf die +// Backends reduzieren, die diese Seite braucht. +// +// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing +// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag — +// nach einem Wechsel hierher und zurueck riss das Dashboard mit +// "Cannot read properties of undefined (reading 'length')" die ganze +// Oberflaeche in die ErrorBoundary. +interface HAProxyStatsPayload { + backends: HAProxyStat[] + frontends: unknown[] + error?: string +} +async function fetchHAProxyStats(): Promise { try { const r = await apiClient.get('/haproxy/stats') - if (!isEnvelope(r.data)) return [] - return (r.data.data as { backends?: HAProxyStat[] }).backends ?? [] - } catch { return [] } + if (!isEnvelope(r.data)) return { backends: [], frontends: [] } + const d = r.data.data as Partial + return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error } + } catch { return { backends: [], frontends: [] } } } export default function DomainDetailPage() { @@ -126,7 +142,8 @@ export default function DomainDetailPage() { const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts }) const { data: haproxyStats } = useQuery({ queryKey: ['haproxy', 'stats'], - queryFn: listHAProxyStats, + queryFn: fetchHAProxyStats, + select: (d: HAProxyStatsPayload) => d.backends, refetchInterval: 15_000, }) diff --git a/management-ui/src/pages/Domains/index.tsx b/management-ui/src/pages/Domains/index.tsx index edc6d57..44cc980 100644 --- a/management-ui/src/pages/Domains/index.tsx +++ b/management-ui/src/pages/Domains/index.tsx @@ -83,12 +83,28 @@ async function listCerts(): Promise { } interface HAProxyStat { backend: string; server: string; status: string } -async function listHAProxyStats(): Promise { +// Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt, +// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier +// IMMER die vollstaendige Antwort cachen und erst per `select` auf die +// Backends reduzieren, die diese Seite braucht. +// +// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing +// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag — +// nach einem Wechsel hierher und zurueck riss das Dashboard mit +// "Cannot read properties of undefined (reading 'length')" die ganze +// Oberflaeche in die ErrorBoundary. +interface HAProxyStatsPayload { + backends: HAProxyStat[] + frontends: unknown[] + error?: string +} +async function fetchHAProxyStats(): Promise { try { const r = await apiClient.get('/haproxy/stats') - if (!isEnvelope(r.data)) return [] - return (r.data.data as { backends?: HAProxyStat[] }).backends ?? [] - } catch { return [] } + if (!isEnvelope(r.data)) return { backends: [], frontends: [] } + const d = r.data.data as Partial + return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error } + } catch { return { backends: [], frontends: [] } } } export default function DomainsPage() { @@ -109,7 +125,8 @@ export default function DomainsPage() { const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts }) const { data: haproxyStats } = useQuery({ queryKey: ['haproxy', 'stats'], - queryFn: listHAProxyStats, + queryFn: fetchHAProxyStats, + select: (d: HAProxyStatsPayload) => d.backends, refetchInterval: 15_000, }) const certByDomain = new Map((certs ?? []).map(c => [c.domain, c])) diff --git a/management-ui/src/pages/RoutingRules/index.tsx b/management-ui/src/pages/RoutingRules/index.tsx index cf8c19b..6fc33e2 100644 --- a/management-ui/src/pages/RoutingRules/index.tsx +++ b/management-ui/src/pages/RoutingRules/index.tsx @@ -58,12 +58,28 @@ function fmtBytes(n: number): string { if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB' return n + ' B' } -async function listHAProxyStats(): Promise { +// Der Cache-Eintrag ['haproxy','stats'] wird mit dem Dashboard geteilt, +// das aus derselben Antwort zusaetzlich `frontends` liest. Deshalb hier +// IMMER die vollstaendige Antwort cachen und erst per `select` auf die +// Backends reduzieren, die diese Seite braucht. +// +// Befund 2026-09-11: Lieferte diese Funktion nur das Backend-Array, hing +// es vom zuletzt besuchten Screen ab, welche Form unter dem Key lag — +// nach einem Wechsel hierher und zurueck riss das Dashboard mit +// "Cannot read properties of undefined (reading 'length')" die ganze +// Oberflaeche in die ErrorBoundary. +interface HAProxyStatsPayload { + backends: HAProxyStat[] + frontends: unknown[] + error?: string +} +async function fetchHAProxyStats(): Promise { try { const r = await apiClient.get('/haproxy/stats') - if (!isEnvelope(r.data)) return [] - return (r.data.data as { backends?: HAProxyStat[] }).backends ?? [] - } catch { return [] } + if (!isEnvelope(r.data)) return { backends: [], frontends: [] } + const d = r.data.data as Partial + return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error } + } catch { return { backends: [], frontends: [] } } } export default function RoutingRulesPage() { @@ -76,7 +92,8 @@ export default function RoutingRulesPage() { const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends }) const { data: haproxyStats } = useQuery({ queryKey: ['haproxy', 'stats'], - queryFn: listHAProxyStats, + queryFn: fetchHAProxyStats, + select: (d: HAProxyStatsPayload) => d.backends, refetchInterval: 15_000, })