Files
edgeguard-native/management-ui/src/pages/RoutingRules/index.tsx
noroot 0f2fba4a62 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>
2026-09-11 11:51:38 +02:00

334 lines
12 KiB
TypeScript

import { useMemo, useState } from 'react'
import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, Tooltip, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ArrowDownOutlined, ArrowUpOutlined, BranchesOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons'
import apiClient, { isEnvelope } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
interface RoutingRule {
id: number
domain_id: number
path_prefix: string
backend_id: number
priority: number
active: boolean
created_at: string
updated_at: string
}
interface RuleFormValues {
domain_id: number
path_prefix: string
backend_id: number
priority: number
active: boolean
}
interface Domain { id: number; name: string }
interface Backend { id: number; name: string; scheme: string }
async function listRules(): Promise<RoutingRule[]> {
const r = await apiClient.get('/routing-rules')
if (!isEnvelope(r.data)) return []
return (r.data.data as { routing_rules?: RoutingRule[] }).routing_rules ?? []
}
async function listDomains(): Promise<Domain[]> {
const r = await apiClient.get('/domains')
if (!isEnvelope(r.data)) return []
return (r.data.data as { domains?: Domain[] }).domains ?? []
}
async function listBackends(): Promise<Backend[]> {
const r = await apiClient.get('/backends')
if (!isEnvelope(r.data)) return []
return (r.data.data as { backends?: Backend[] }).backends ?? []
}
interface HAProxyStat { backend: string; status: string; sessions: number; bytes_in: number; bytes_out: number }
function fmtBytes(n: number): string {
if (n >= 1_073_741_824) return (n / 1_073_741_824).toFixed(1) + ' GB'
if (n >= 1_048_576) return (n / 1_048_576).toFixed(1) + ' MB'
if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
return n + ' B'
}
// 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 {
const r = await apiClient.get('/haproxy/stats')
if (!isEnvelope(r.data)) return { backends: [], frontends: [] }
const d = r.data.data as Partial<HAProxyStatsPayload>
return { backends: d.backends ?? [], frontends: d.frontends ?? [], error: d.error }
} catch { return { backends: [], frontends: [] } }
}
export default function RoutingRulesPage() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const { data: rules, isLoading } = useQuery({ queryKey: ['routing-rules'], queryFn: listRules })
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'],
queryFn: fetchHAProxyStats,
select: (d: HAProxyStatsPayload) => d.backends,
refetchInterval: 15_000,
})
const domainName = (id: number) => domains?.find((d) => d.id === id)?.name ?? `#${id}`
const backendLabel = (id: number) => {
const b = backends?.find((x) => x.id === id)
return b ? `${b.name} (${b.scheme})` : `#${id}`
}
const backendHealth = (id: number) => {
if (!haproxyStats?.length) return null
const servers = haproxyStats.filter(s => s.backend === `eg_backend_${id}`)
if (!servers.length) return null
const up = servers.some(s => s.status === 'UP')
const sessions = servers.reduce((a, s) => a + (s.sessions ?? 0), 0)
const bytesIn = servers.reduce((a, s) => a + (s.bytes_in ?? 0), 0)
const bytesOut = servers.reduce((a, s) => a + (s.bytes_out ?? 0), 0)
return { up, sessions, bytesIn, bytesOut }
}
const [editing, setEditing] = useState<RoutingRule | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<RuleFormValues>()
const create = useMutation({
mutationFn: async (v: RuleFormValues) => { await apiClient.post('/routing-rules', v) },
onSuccess: () => {
message.success(t('common.save'))
setCreating(false)
form.resetFields()
void qc.invalidateQueries({ queryKey: ['routing-rules'] })
},
onError: (e: Error) => message.error(e.message),
})
const update = useMutation({
mutationFn: async ({ id, v }: { id: number; v: RuleFormValues }) => {
await apiClient.put(`/routing-rules/${id}`, v)
},
onSuccess: () => {
message.success(t('common.save'))
setEditing(null)
form.resetFields()
void qc.invalidateQueries({ queryKey: ['routing-rules'] })
},
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => { await apiClient.delete(`/routing-rules/${id}`) },
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['routing-rules'] }) },
})
const quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: RoutingRule; checked: boolean }) => {
const { id: _id, created_at: _ca, updated_at: _ua, ...body } = row
await apiClient.put(`/routing-rules/${id}`, { ...body, active: checked })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['routing-rules'] }) },
onError: (e: Error) => message.error(e.message),
})
const sortedRules = useMemo(
() => [...(rules ?? [])].sort((a, b) => a.priority - b.priority),
[rules],
)
const swapRules = useMutation({
mutationFn: async ({ a, b }: { a: RoutingRule; b: RoutingRule }) => {
const stripMeta = ({ id: _id, created_at: _ca, updated_at: _ua, ...r }: RoutingRule) => r
await apiClient.put(`/routing-rules/${a.id}`, { ...stripMeta(a), priority: b.priority })
await apiClient.put(`/routing-rules/${b.id}`, { ...stripMeta(b), priority: a.priority })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['routing-rules'] }) },
onError: (e: Error) => message.error(e.message),
})
const columns: ColumnsType<RoutingRule> = [
{ title: t('routing.domain'), dataIndex: 'domain_id', key: 'domain', render: (id: number) => domainName(id) },
{ title: t('routing.pathPrefix'), dataIndex: 'path_prefix', key: 'path' },
{
title: t('routing.backend'), dataIndex: 'backend_id', key: 'backend',
render: (id: number) => {
const h = backendHealth(id)
const tip = h ? `${h.sessions} sess · ↓${fmtBytes(h.bytesIn)}${fmtBytes(h.bytesOut)}` : undefined
return (
<Space size={4}>
<span>{backendLabel(id)}</span>
{h && (
<Tooltip title={tip}>
<Tag color={h.up ? 'green' : 'red'} style={{ margin: 0 }}>{h.up ? t('common.up') : t('common.down')}</Tag>
</Tooltip>
)}
</Space>
)
},
},
{ title: t('routing.priority'), dataIndex: 'priority', key: 'priority' },
{
title: t('routing.active'), dataIndex: 'active', key: 'active', width: 80,
render: (v: boolean, row: RoutingRule) => (
<Switch
size="small"
checked={v}
disabled={isViewer}
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
/>
),
},
{
title: '', key: 'move', width: 64,
render: (_, row) => {
const idx = sortedRules.findIndex(r => r.id === row.id)
const swapping = swapRules.isPending
return (
<Space size={2}>
<Tooltip title={t('fw.rule.moveUp')}>
<Button size="small" icon={<ArrowUpOutlined />}
disabled={isViewer || idx <= 0 || swapping}
onClick={() => swapRules.mutate({ a: row, b: sortedRules[idx - 1] })} />
</Tooltip>
<Tooltip title={t('fw.rule.moveDown')}>
<Button size="small" icon={<ArrowDownOutlined />}
disabled={isViewer || idx >= sortedRules.length - 1 || swapping}
onClick={() => swapRules.mutate({ a: row, b: sortedRules[idx + 1] })} />
</Tooltip>
</Space>
)
},
},
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<ActionButtons
onEdit={() => {
setEditing(row)
form.setFieldsValue({
domain_id: row.domain_id,
path_prefix: row.path_prefix,
backend_id: row.backend_id,
priority: row.priority,
active: row.active,
})
}}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('routing.deleteConfirm')}
/>
),
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ priority: 100, path_prefix: '/', active: true })
}
return (
<div>
<PageHeader
icon={<BranchesOutlined />}
title={t('routing.title')}
subtitle={t('routing.intro')}
/>
<DataTable
rowKey="id"
loading={isLoading}
dataSource={sortedRules}
columns={columns}
extraActions={
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
{t('routing.addRule')}
</Button>
</Tooltip>
}
emptyContent={
<EmptyState
icon={<BranchesOutlined />}
title={t('routing.emptyTitle')}
description={t('routing.emptyDesc')}
action={
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
{t('routing.addRule')}
</Button>
</Tooltip>
}
/>
}
/>
<Modal
title={editing ? t('routing.editRule') : t('routing.addRule')}
open={editing !== null || creating}
onCancel={() => { setEditing(null); setCreating(false) }}
onOk={() => { void form.submit() }}
confirmLoading={create.isPending || update.isPending}
destroyOnHidden
>
<Form
form={form}
layout="vertical"
onFinish={(v) => {
if (editing) update.mutate({ id: editing.id, v })
else create.mutate(v)
}}
>
<Form.Item label={t('routing.domain')} name="domain_id" rules={[{ required: true }]}>
<Select
options={(domains ?? []).map((d) => ({ value: d.id, label: d.name }))}
placeholder={t('routing.selectDomain')}
/>
</Form.Item>
<Form.Item
label={t('routing.pathPrefix')}
name="path_prefix"
extra={t('routing.pathPrefixHint')}
rules={[
{ required: true },
{ pattern: /^\/[a-zA-Z0-9\-._~!$&'()*+,;=:@/%]*$/, message: t('routing.pathPrefixInvalid') },
]}
>
<Input placeholder="/" />
</Form.Item>
<Form.Item label={t('routing.backend')} name="backend_id" rules={[{ required: true }]}>
<Select
options={(backends ?? []).map((b) => ({ value: b.id, label: `${b.name} (${b.scheme})` }))}
placeholder={t('routing.selectBackend')}
/>
</Form.Item>
<Form.Item label={t('routing.priority')} name="priority" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('routing.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
)
}