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>
445 lines
18 KiB
TypeScript
445 lines
18 KiB
TypeScript
import { useState } from 'react'
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
|
import {
|
|
Button, Card, Col, Form, Input, InputNumber, Modal,
|
|
Row, Select, Switch, Table, Tag, Tooltip, Typography, message,
|
|
} from 'antd'
|
|
import type { ColumnsType } from 'antd/es/table'
|
|
import { ArrowLeftOutlined, DatabaseOutlined, PlusOutlined } from '@ant-design/icons'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
import apiClient, { isEnvelope } from '../../api/client'
|
|
import PageHeader from '../../components/PageHeader'
|
|
import ActionButtons from '../../components/ActionButtons'
|
|
import { useAuthStore } from '../../stores/auth'
|
|
|
|
const { Text } = Typography
|
|
|
|
interface Backend {
|
|
id: number; name: string; scheme: string
|
|
health_check_path?: string | null
|
|
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
|
websocket: boolean; force_http1: boolean
|
|
server_timeout_seconds?: number | null
|
|
active: boolean
|
|
}
|
|
interface BackendFormValues {
|
|
name: string; scheme: 'http' | 'https'
|
|
health_check_path?: string
|
|
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
|
websocket: boolean; force_http1: boolean
|
|
server_timeout_seconds?: number | null
|
|
active: boolean
|
|
domain_ids?: number[]
|
|
}
|
|
interface BackendServer {
|
|
id: number; backend_id: number; name: string
|
|
address: string; port: number
|
|
weight: number; backup: boolean; active: boolean
|
|
}
|
|
interface ServerFormValues {
|
|
name: string; address: string; port: number
|
|
weight: number; backup: boolean; active: boolean
|
|
}
|
|
interface DomainFull {
|
|
id: number; name: string; active: boolean
|
|
primary_backend_id?: number | null
|
|
http_to_https: boolean; hsts_enabled: boolean
|
|
hsts_max_age: number; hsts_subdomains: boolean; hsts_preload: boolean
|
|
maintenance_mode: boolean; maintenance_message?: string | null
|
|
www_redirect: string; rate_limit_rps: number
|
|
max_body_kb: number; disable_h3: boolean; notes?: string | null
|
|
}
|
|
|
|
async function getBackend(id: number): Promise<Backend | null> {
|
|
const r = await apiClient.get(`/backends/${id}`)
|
|
if (!isEnvelope(r.data)) return null
|
|
return r.data.data as Backend
|
|
}
|
|
async function listServers(backendID: number): Promise<BackendServer[]> {
|
|
const r = await apiClient.get(`/backends/${backendID}/servers`)
|
|
if (!isEnvelope(r.data)) return []
|
|
return (r.data.data as { servers?: BackendServer[] }).servers ?? []
|
|
}
|
|
async function listDomains(): Promise<DomainFull[]> {
|
|
const r = await apiClient.get('/domains')
|
|
if (!isEnvelope(r.data)) return []
|
|
return (r.data.data as { domains?: DomainFull[] }).domains ?? []
|
|
}
|
|
|
|
interface HAProxyStat {
|
|
backend: string; server: string; status: string
|
|
sessions: number; bytes_in: number; bytes_out: number
|
|
req_tot: number; req_rate: number
|
|
last_change_sec: number; health: string
|
|
}
|
|
// 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: [] } }
|
|
}
|
|
|
|
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'
|
|
}
|
|
|
|
// Mirror of haproxy.go safeID: replaces any char outside [a-zA-Z0-9_-] with '_'.
|
|
// HAProxy uses this to generate server tokens; we need it to match stat rows.
|
|
function safeID(s: string): string {
|
|
const out = s.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
return out || 'unnamed'
|
|
}
|
|
|
|
export default function BackendDetailPage() {
|
|
const { t } = useTranslation()
|
|
const { id } = useParams<{ id: string }>()
|
|
const navigate = useNavigate()
|
|
const qc = useQueryClient()
|
|
const backendID = Number(id)
|
|
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
|
|
|
const { data: backend, isLoading } = useQuery({
|
|
queryKey: ['backend', backendID],
|
|
queryFn: () => getBackend(backendID),
|
|
enabled: !isNaN(backendID),
|
|
})
|
|
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
|
|
const { data: haproxyStats } = useQuery({
|
|
queryKey: ['haproxy', 'stats'],
|
|
queryFn: fetchHAProxyStats,
|
|
select: (d: HAProxyStatsPayload) => d.backends,
|
|
refetchInterval: 10_000,
|
|
})
|
|
const [form] = Form.useForm<BackendFormValues>()
|
|
|
|
async function syncDomainAttachments(selected: number[]) {
|
|
const all = domains ?? []
|
|
const wasAttached = new Set(all.filter(d => d.primary_backend_id === backendID).map(d => d.id))
|
|
const want = new Set(selected)
|
|
const puts: Promise<unknown>[] = []
|
|
for (const id of [...want].filter(id => !wasAttached.has(id))) {
|
|
const d = all.find(x => x.id === id)
|
|
if (d) puts.push(apiClient.put(`/domains/${id}`, { ...d, primary_backend_id: backendID }))
|
|
}
|
|
for (const id of [...wasAttached].filter(id => !want.has(id))) {
|
|
const d = all.find(x => x.id === id)
|
|
if (d) puts.push(apiClient.put(`/domains/${id}`, { ...d, primary_backend_id: null }))
|
|
}
|
|
if (puts.length) await Promise.all(puts)
|
|
}
|
|
|
|
const update = useMutation({
|
|
mutationFn: async (v: BackendFormValues) => {
|
|
const { domain_ids, ...body } = v
|
|
await apiClient.put(`/backends/${backendID}`, body)
|
|
await syncDomainAttachments(domain_ids ?? [])
|
|
},
|
|
onSuccess: () => {
|
|
message.success(t('common.save'))
|
|
void qc.invalidateQueries({ queryKey: ['backends'] })
|
|
void qc.invalidateQueries({ queryKey: ['backend', backendID] })
|
|
void qc.invalidateQueries({ queryKey: ['domains'] })
|
|
},
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
|
|
if (isLoading || !backend) return null
|
|
|
|
const attached = (domains ?? []).filter(d => d.primary_backend_id === backendID)
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
icon={<DatabaseOutlined />}
|
|
title={backend.name}
|
|
subtitle={`${backend.scheme.toUpperCase()} · ${backend.lb_algorithm}`}
|
|
extra={
|
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/backends')}>
|
|
{t('backends.backToList')}
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<Row gutter={24}>
|
|
<Col xs={24} lg={10}>
|
|
<Card size="small" title={t('backends.settingsCard')} className="mb-16">
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
initialValues={{
|
|
name: backend.name,
|
|
scheme: backend.scheme,
|
|
health_check_path: backend.health_check_path ?? undefined,
|
|
lb_algorithm: backend.lb_algorithm,
|
|
websocket: backend.websocket,
|
|
force_http1: backend.force_http1,
|
|
server_timeout_seconds: backend.server_timeout_seconds ?? undefined,
|
|
active: backend.active,
|
|
domain_ids: attached.map(d => d.id),
|
|
}}
|
|
onFinish={(v) => update.mutate(v)}
|
|
>
|
|
<Form.Item label={t('backends.name')} name="name" rules={[{ required: true }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item label={t('backends.scheme')} name="scheme" rules={[{ required: true }]}>
|
|
<Select options={[{ value: 'http', label: 'http' }, { value: 'https', label: 'https' }]} />
|
|
</Form.Item>
|
|
<Form.Item label={t('backends.lbAlgo')} name="lb_algorithm" rules={[{ required: true }]}
|
|
extra={t('backends.lbAlgoHint')}>
|
|
<Select options={[
|
|
{ value: 'roundrobin', label: 'roundrobin' },
|
|
{ value: 'leastconn', label: 'leastconn' },
|
|
{ value: 'source', label: 'source' },
|
|
]} />
|
|
</Form.Item>
|
|
<Form.Item label={t('backends.healthCheck')} name="health_check_path"
|
|
extra={t('backends.healthCheckHint')}>
|
|
<Input placeholder="/health" allowClear />
|
|
</Form.Item>
|
|
<Form.Item label={t('backends.websocket')} name="websocket" valuePropName="checked"
|
|
extra={t('backends.websocketHint')}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label={t('backends.forceHttp1')} name="force_http1" valuePropName="checked"
|
|
extra={t('backends.forceHttp1Hint')}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label={t('backends.serverTimeout')} name="server_timeout_seconds"
|
|
extra={t('backends.serverTimeoutHint')}>
|
|
<InputNumber min={1} max={86400} step={30}
|
|
style={{ width: '100%' }} addonAfter="s" placeholder="60 (default)" />
|
|
</Form.Item>
|
|
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label={t('backends.attachedDomains')} name="domain_ids"
|
|
extra={t('backends.attachedDomainsHint')}>
|
|
<Select
|
|
mode="multiple" allowClear showSearch optionFilterProp="label"
|
|
placeholder={t('backends.selectDomains')}
|
|
options={(domains ?? []).map(d => ({
|
|
value: d.id,
|
|
label: d.primary_backend_id && d.primary_backend_id !== backendID
|
|
? `${d.name} (${t('backends.attachedToOther', { id: d.primary_backend_id })})`
|
|
: d.name,
|
|
}))}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item>
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" htmlType="submit" loading={update.isPending} disabled={isViewer}>
|
|
{t('common.save')}
|
|
</Button>
|
|
</Tooltip>
|
|
</Form.Item>
|
|
</Form>
|
|
</Card>
|
|
</Col>
|
|
|
|
<Col xs={24} lg={14}>
|
|
<Card size="small" title={t('backends.serversIn', { name: backend.name })}>
|
|
<ServerPanel backendID={backendID} haproxyStats={haproxyStats ?? []} isViewer={isViewer} />
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ServerPanel({ backendID, haproxyStats, isViewer }: { backendID: number; haproxyStats: HAProxyStat[]; isViewer: boolean }) {
|
|
const { t } = useTranslation()
|
|
const qc = useQueryClient()
|
|
const [open, setOpen] = useState(false)
|
|
const [editing, setEditing] = useState<BackendServer | null>(null)
|
|
const [form] = Form.useForm<ServerFormValues>()
|
|
|
|
const { data: servers, isLoading } = useQuery({
|
|
queryKey: ['backend-servers', backendID],
|
|
queryFn: () => listServers(backendID),
|
|
})
|
|
|
|
const create = useMutation({
|
|
mutationFn: async (v: ServerFormValues) => {
|
|
await apiClient.post(`/backends/${backendID}/servers`, v)
|
|
},
|
|
onSuccess: () => {
|
|
message.success(t('common.save'))
|
|
setOpen(false); form.resetFields()
|
|
void qc.invalidateQueries({ queryKey: ['backend-servers', backendID] })
|
|
void qc.invalidateQueries({ queryKey: ['backend-server-counts'] })
|
|
},
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
const update = useMutation({
|
|
mutationFn: async ({ id, v }: { id: number; v: ServerFormValues }) => {
|
|
await apiClient.put(`/backend-servers/${id}`, { ...v, backend_id: backendID })
|
|
},
|
|
onSuccess: () => {
|
|
message.success(t('common.save'))
|
|
setEditing(null); form.resetFields()
|
|
void qc.invalidateQueries({ queryKey: ['backend-servers', backendID] })
|
|
},
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
const del = useMutation({
|
|
mutationFn: async (id: number) => { await apiClient.delete(`/backend-servers/${id}`) },
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: ['backend-servers', backendID] })
|
|
void qc.invalidateQueries({ queryKey: ['backend-server-counts'] })
|
|
},
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
const quickToggle = useMutation({
|
|
mutationFn: async ({ id, row, checked }: { id: number; row: BackendServer; checked: boolean }) => {
|
|
const { id: _id, ...body } = row
|
|
await apiClient.put(`/backend-servers/${id}`, { ...body, active: checked })
|
|
},
|
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['backend-servers', backendID] }) },
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
|
|
const cols: ColumnsType<BackendServer> = [
|
|
{
|
|
title: t('backends.server.name'), dataIndex: 'name',
|
|
render: (s: string) => <code>{s}</code>,
|
|
},
|
|
{
|
|
title: t('backends.server.target'), key: 'tgt',
|
|
render: (_, r) => <Text code>{r.address}:{r.port}</Text>,
|
|
},
|
|
{ title: t('backends.server.weight'), dataIndex: 'weight', width: 80 },
|
|
{
|
|
title: t('backends.server.backup'), dataIndex: 'backup', width: 90,
|
|
render: (v: boolean) => v ? <Tag color="purple">Backup</Tag> : '—',
|
|
},
|
|
{
|
|
title: t('backends.active'), dataIndex: 'active', width: 80,
|
|
render: (v: boolean, r: BackendServer) => (
|
|
<Switch
|
|
size="small"
|
|
checked={v}
|
|
disabled={isViewer}
|
|
loading={quickToggle.isPending && quickToggle.variables?.id === r.id}
|
|
onChange={(checked) => quickToggle.mutate({ id: r.id, row: r, checked })}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
title: t('backends.server.live'), key: 'live', width: 160,
|
|
render: (_, r) => {
|
|
const stat = haproxyStats.find(
|
|
s => s.backend === `eg_backend_${backendID}` && s.server === safeID(r.name)
|
|
)
|
|
if (!stat) return <Text type="secondary" style={{ fontSize: 11 }}>—</Text>
|
|
const color = stat.status === 'UP' ? 'green' : stat.status === 'no check' ? 'default' : 'red'
|
|
return (
|
|
<Tooltip title={`↓${fmtBytes(stat.bytes_in)} ↑${fmtBytes(stat.bytes_out)} · ${stat.req_tot} req total · ${stat.health || stat.status}`}>
|
|
<Tag color={color} style={{ margin: 0, fontSize: 11 }}>{stat.status}</Tag>
|
|
<Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>
|
|
{stat.sessions} sess{stat.req_rate > 0 ? ` · ${stat.req_rate}/s` : ''}
|
|
</Text>
|
|
</Tooltip>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
title: t('common.actions'), key: 'a', width: 100,
|
|
render: (_, r) => (
|
|
<ActionButtons
|
|
onEdit={() => {
|
|
setEditing(r)
|
|
form.setFieldsValue({
|
|
name: r.name, address: r.address, port: r.port,
|
|
weight: r.weight, backup: r.backup, active: r.active,
|
|
})
|
|
}}
|
|
onDelete={() => del.mutate(r.id)}
|
|
deleteConfirm={t('backends.server.deleteConfirm', { name: r.name })}
|
|
/>
|
|
),
|
|
},
|
|
]
|
|
|
|
return (
|
|
<>
|
|
<div className="mb-8" style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" icon={<PlusOutlined />} size="small" disabled={isViewer} onClick={() => {
|
|
setOpen(true); form.resetFields()
|
|
form.setFieldsValue({ weight: 100, backup: false, active: true, port: 8080 })
|
|
}}>
|
|
{t('backends.server.add')}
|
|
</Button>
|
|
</Tooltip>
|
|
</div>
|
|
<Table
|
|
size="small" rowKey="id" loading={isLoading}
|
|
dataSource={servers ?? []} columns={cols}
|
|
pagination={false}
|
|
locale={{ emptyText: t('backends.server.empty') }}
|
|
/>
|
|
<Modal
|
|
title={editing ? t('backends.server.edit') : t('backends.server.add')}
|
|
open={open || editing !== null}
|
|
onCancel={() => { setOpen(false); setEditing(null); form.resetFields() }}
|
|
onOk={() => { void form.submit() }}
|
|
confirmLoading={create.isPending || update.isPending}
|
|
destroyOnHidden
|
|
>
|
|
<Form form={form} layout="vertical"
|
|
onFinish={(v) => editing ? update.mutate({ id: editing.id, v }) : create.mutate(v)}>
|
|
<Form.Item label={t('backends.server.name')} name="name" rules={[{ required: true }]}>
|
|
<Input placeholder="vmm-1" />
|
|
</Form.Item>
|
|
<Form.Item
|
|
label={t('backends.server.address')}
|
|
name="address"
|
|
rules={[
|
|
{ required: true },
|
|
{ pattern: /^(\d{1,3}\.){3}\d{1,3}$|^[0-9a-fA-F:]{2,39}$|^[a-zA-Z0-9]([a-zA-Z0-9\-.]{0,61}[a-zA-Z0-9])?$/, message: t('backends.server.addressInvalid') },
|
|
]}
|
|
>
|
|
<Input placeholder="10.0.0.11" />
|
|
</Form.Item>
|
|
<Form.Item label={t('backends.server.port')} name="port" rules={[{ required: true }]}>
|
|
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item label={t('backends.server.weight')} name="weight" extra={t('backends.server.weightHint')}>
|
|
<InputNumber min={0} max={256} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item label={t('backends.server.backup')} name="backup" valuePropName="checked"
|
|
extra={t('backends.server.backupHint')}>
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</>
|
|
)
|
|
}
|