fix(ui): Dashboard riss bei SPA-Navigation die ganze Oberflaeche
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 <noreply@anthropic.com>
This commit is contained in:
@@ -74,12 +74,28 @@ interface HAProxyStat {
|
|||||||
req_tot: number; req_rate: number
|
req_tot: number; req_rate: number
|
||||||
last_change_sec: number; health: string
|
last_change_sec: number; health: string
|
||||||
}
|
}
|
||||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
// 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<HAProxyStatsPayload> {
|
||||||
try {
|
try {
|
||||||
const r = await apiClient.get('/haproxy/stats')
|
const r = await apiClient.get('/haproxy/stats')
|
||||||
if (!isEnvelope(r.data)) return []
|
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
|
||||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
const d = r.data.data as Partial<HAProxyStatsPayload>
|
||||||
} catch { return [] }
|
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
|
||||||
|
} catch { return { backends: [], frontends: [] } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtBytes(n: number): string {
|
function fmtBytes(n: number): string {
|
||||||
@@ -112,7 +128,8 @@ export default function BackendDetailPage() {
|
|||||||
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
|
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
|
||||||
const { data: haproxyStats } = useQuery({
|
const { data: haproxyStats } = useQuery({
|
||||||
queryKey: ['haproxy', 'stats'],
|
queryKey: ['haproxy', 'stats'],
|
||||||
queryFn: listHAProxyStats,
|
queryFn: fetchHAProxyStats,
|
||||||
|
select: (d: HAProxyStatsPayload) => d.backends,
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
})
|
})
|
||||||
const [form] = Form.useForm<BackendFormValues>()
|
const [form] = Form.useForm<BackendFormValues>()
|
||||||
|
|||||||
@@ -118,12 +118,28 @@ function fmtBytes(n: number): string {
|
|||||||
if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
|
if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
|
||||||
return n + ' B'
|
return n + ' B'
|
||||||
}
|
}
|
||||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
// 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<HAProxyStatsPayload> {
|
||||||
try {
|
try {
|
||||||
const r = await apiClient.get('/haproxy/stats')
|
const r = await apiClient.get('/haproxy/stats')
|
||||||
if (!isEnvelope(r.data)) return []
|
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
|
||||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
const d = r.data.data as Partial<HAProxyStatsPayload>
|
||||||
} catch { return [] }
|
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
|
||||||
|
} catch { return { backends: [], frontends: [] } }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BackendsPage() {
|
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 haproxyService = services?.find(s => s.unit === 'haproxy.service' || s.unit === 'haproxy')
|
||||||
const { data: haproxyStats } = useQuery({
|
const { data: haproxyStats } = useQuery({
|
||||||
queryKey: ['haproxy', 'stats'],
|
queryKey: ['haproxy', 'stats'],
|
||||||
queryFn: listHAProxyStats,
|
queryFn: fetchHAProxyStats,
|
||||||
|
select: (d: HAProxyStatsPayload) => d.backends,
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -613,11 +613,11 @@ function VIPCard({ data }: { data?: VIPStatus | null }) {
|
|||||||
>
|
>
|
||||||
{!data ? (
|
{!data ? (
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>—</Text>
|
<Text type="secondary" style={{ fontSize: 12 }}>—</Text>
|
||||||
) : data.vips.length === 0 ? (
|
) : (data.vips ?? []).length === 0 ? (
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.vipCard.noVips')}</Text>
|
<Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.vipCard.noVips')}</Text>
|
||||||
) : (
|
) : (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size={0}>
|
<Space direction="vertical" style={{ width: '100%' }} size={0}>
|
||||||
{data.vips.map((v) => (
|
{(data.vips ?? []).map((v) => (
|
||||||
<div key={v.address} style={{
|
<div key={v.address} style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
padding: '5px 0', borderBottom: '1px solid #F1F5F9',
|
padding: '5px 0', borderBottom: '1px solid #F1F5F9',
|
||||||
@@ -715,7 +715,7 @@ function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) {
|
|||||||
className="h-100"
|
className="h-100"
|
||||||
title={<><DatabaseOutlined style={{ color: '#0EA5E9' }} /> {t('dashboard.haproxyCard.title')}</>}
|
title={<><DatabaseOutlined style={{ color: '#0EA5E9' }} /> {t('dashboard.haproxyCard.title')}</>}
|
||||||
extra={
|
extra={
|
||||||
stats.frontends.length > 0 && (
|
(stats.frontends ?? []).length > 0 && (
|
||||||
<Space size={8}>
|
<Space size={8}>
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>{totalSessions} sess</Text>
|
<Text type="secondary" style={{ fontSize: 11 }}>{totalSessions} sess</Text>
|
||||||
{totalReqRate > 0 && <Text type="secondary" style={{ fontSize: 11 }}>{totalReqRate}/s</Text>}
|
{totalReqRate > 0 && <Text type="secondary" style={{ fontSize: 11 }}>{totalReqRate}/s</Text>}
|
||||||
@@ -731,7 +731,7 @@ function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Listeners */}
|
{/* Listeners */}
|
||||||
{stats.frontends.length > 0 && (
|
{(stats.frontends ?? []).length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Text type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
<Text type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
{t('dashboard.haproxyCard.frontends')}
|
{t('dashboard.haproxyCard.frontends')}
|
||||||
@@ -752,7 +752,7 @@ function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Backends */}
|
{/* Backends */}
|
||||||
{stats.backends.length === 0 && !stats.error ? (
|
{(stats.backends ?? []).length === 0 && !stats.error ? (
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.haproxyCard.empty')}</Text>
|
<Text type="secondary" style={{ fontSize: 12 }}>{t('dashboard.haproxyCard.empty')}</Text>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -101,12 +101,28 @@ async function listCerts(): Promise<TLSCertLite[]> {
|
|||||||
if (!isEnvelope(r.data)) return []
|
if (!isEnvelope(r.data)) return []
|
||||||
return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? []
|
return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? []
|
||||||
}
|
}
|
||||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
// 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<HAProxyStatsPayload> {
|
||||||
try {
|
try {
|
||||||
const r = await apiClient.get('/haproxy/stats')
|
const r = await apiClient.get('/haproxy/stats')
|
||||||
if (!isEnvelope(r.data)) return []
|
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
|
||||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
const d = r.data.data as Partial<HAProxyStatsPayload>
|
||||||
} catch { return [] }
|
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
|
||||||
|
} catch { return { backends: [], frontends: [] } }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DomainDetailPage() {
|
export default function DomainDetailPage() {
|
||||||
@@ -126,7 +142,8 @@ export default function DomainDetailPage() {
|
|||||||
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
||||||
const { data: haproxyStats } = useQuery({
|
const { data: haproxyStats } = useQuery({
|
||||||
queryKey: ['haproxy', 'stats'],
|
queryKey: ['haproxy', 'stats'],
|
||||||
queryFn: listHAProxyStats,
|
queryFn: fetchHAProxyStats,
|
||||||
|
select: (d: HAProxyStatsPayload) => d.backends,
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -83,12 +83,28 @@ async function listCerts(): Promise<TLSCertLite[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface HAProxyStat { backend: string; server: string; status: string }
|
interface HAProxyStat { backend: string; server: string; status: string }
|
||||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
// 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<HAProxyStatsPayload> {
|
||||||
try {
|
try {
|
||||||
const r = await apiClient.get('/haproxy/stats')
|
const r = await apiClient.get('/haproxy/stats')
|
||||||
if (!isEnvelope(r.data)) return []
|
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
|
||||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
const d = r.data.data as Partial<HAProxyStatsPayload>
|
||||||
} catch { return [] }
|
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
|
||||||
|
} catch { return { backends: [], frontends: [] } }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DomainsPage() {
|
export default function DomainsPage() {
|
||||||
@@ -109,7 +125,8 @@ export default function DomainsPage() {
|
|||||||
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
||||||
const { data: haproxyStats } = useQuery({
|
const { data: haproxyStats } = useQuery({
|
||||||
queryKey: ['haproxy', 'stats'],
|
queryKey: ['haproxy', 'stats'],
|
||||||
queryFn: listHAProxyStats,
|
queryFn: fetchHAProxyStats,
|
||||||
|
select: (d: HAProxyStatsPayload) => d.backends,
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
})
|
})
|
||||||
const certByDomain = new Map((certs ?? []).map(c => [c.domain, c]))
|
const certByDomain = new Map((certs ?? []).map(c => [c.domain, c]))
|
||||||
|
|||||||
@@ -58,12 +58,28 @@ function fmtBytes(n: number): string {
|
|||||||
if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
|
if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
|
||||||
return n + ' B'
|
return n + ' B'
|
||||||
}
|
}
|
||||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
// 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<HAProxyStatsPayload> {
|
||||||
try {
|
try {
|
||||||
const r = await apiClient.get('/haproxy/stats')
|
const r = await apiClient.get('/haproxy/stats')
|
||||||
if (!isEnvelope(r.data)) return []
|
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
|
||||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
const d = r.data.data as Partial<HAProxyStatsPayload>
|
||||||
} catch { return [] }
|
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
|
||||||
|
} catch { return { backends: [], frontends: [] } }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RoutingRulesPage() {
|
export default function RoutingRulesPage() {
|
||||||
@@ -76,7 +92,8 @@ export default function RoutingRulesPage() {
|
|||||||
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
|
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
|
||||||
const { data: haproxyStats } = useQuery({
|
const { data: haproxyStats } = useQuery({
|
||||||
queryKey: ['haproxy', 'stats'],
|
queryKey: ['haproxy', 'stats'],
|
||||||
queryFn: listHAProxyStats,
|
queryFn: fetchHAProxyStats,
|
||||||
|
select: (d: HAProxyStatsPayload) => d.backends,
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user