Files
edgeguard-native/management-ui/src/pages/Domains/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

451 lines
16 KiB
TypeScript

import { useState } from 'react'
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { GlobalOutlined, PlusOutlined, StopOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
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 StatusDot from '../../components/StatusDot'
import apiClient, { isEnvelope } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
interface Domain {
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: '' | 'to-naked' | 'to-www'
rate_limit_rps: number
max_body_kb: number
disable_h3: boolean
notes?: string
created_at: string
updated_at: string
}
interface DomainFormValues {
name: string
active: boolean
http_to_https: boolean
hsts_enabled: boolean
hsts_max_age: number
hsts_subdomains: boolean
hsts_preload: boolean
maintenance_mode: boolean
maintenance_message?: string
www_redirect: '' | 'to-naked' | 'to-www'
rate_limit_rps: number
max_body_kb: number
primary_backend_id?: number | null
notes?: string
}
async function listDomains(): Promise<Domain[]> {
const r = await apiClient.get('/domains')
if (!isEnvelope(r.data)) return []
const payload = r.data.data as { domains?: Domain[] }
return payload.domains ?? []
}
interface BackendLite {
id: number
name: string
scheme: string
active: boolean
}
async function listBackends(): Promise<BackendLite[]> {
const r = await apiClient.get('/backends')
if (!isEnvelope(r.data)) return []
return (r.data.data as { backends?: BackendLite[] }).backends ?? []
}
interface TLSCertLite {
domain: string
status: 'pending' | 'active' | 'renewing' | 'expired' | 'error'
not_after?: string | null
}
async function listCerts(): Promise<TLSCertLite[]> {
const r = await apiClient.get('/tls-certs')
if (!isEnvelope(r.data)) return []
return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? []
}
interface HAProxyStat { backend: string; server: string; status: 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: [] } }
}
export default function DomainsPage() {
const { t } = useTranslation()
const qc = useQueryClient()
const navigate = useNavigate()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const { data, isLoading } = useQuery({
queryKey: ['domains'],
queryFn: listDomains,
})
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
// TLS-Certs nebenher laden, damit wir pro Domain den Cert-Status
// (vorhanden / gültig / ablaufend / fehlt) als Spalte zeigen können.
// Operator sieht so auf einen Blick welche Domains noch self-signed
// sind und welche bereits ein gültiges ACME-Cert haben.
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'],
queryFn: fetchHAProxyStats,
select: (d: HAProxyStatsPayload) => d.backends,
refetchInterval: 15_000,
})
const certByDomain = new Map((certs ?? []).map(c => [c.domain, c]))
const backendById = (id?: number | null) => backends?.find(b => b.id === id)
// Gibt 'UP', 'DOWN' oder null zurück. HAProxy-Backend heißt eg_backend_<id>.
// Wir aggregieren alle Server: wenn mind. einer UP → UP, sonst DOWN.
const backendHealth = (id?: number | null): 'UP' | 'DOWN' | null => {
if (!id || !haproxyStats?.length) return null
const name = `eg_backend_${id}`
const servers = haproxyStats.filter(s => s.backend === name)
if (!servers.length) return null
return servers.some(s => s.status === 'UP') ? 'UP' : 'DOWN'
}
const [creating, setCreating] = useState(false)
const [quickBackendOpen, setQuickBackendOpen] = useState(false)
const [form] = Form.useForm<DomainFormValues>()
const [quickBackendForm] = Form.useForm<{ name: string; scheme: 'http' | 'https'; address: string; port: number }>()
const quickCreateBackend = useMutation({
mutationFn: async (v: { name: string; scheme: 'http' | 'https'; address: string; port: number }) => {
// 1) Backend anlegen
const bRes = await apiClient.post('/backends', {
name: v.name, scheme: v.scheme, lb_algorithm: 'roundrobin',
websocket: false, active: true,
})
const bId = (bRes.data?.data as { id?: number })?.id
if (!bId) throw new Error('backend id missing in response')
// 2) Ersten Server reinhängen
await apiClient.post(`/backends/${bId}/servers`, {
backend_id: bId, name: v.address.replace(/[^a-zA-Z0-9-]/g, '-'),
address: v.address, port: v.port, weight: 100, active: true,
})
return bId
},
onSuccess: (bId) => {
message.success(t('domains.quickBackendCreated'))
void qc.invalidateQueries({ queryKey: ['backends'] })
// Frisch erstellten Backend automatisch im Domain-Form selektieren.
form.setFieldsValue({ primary_backend_id: bId })
setQuickBackendOpen(false)
quickBackendForm.resetFields()
},
onError: (e: Error) => message.error(t('domains.quickBackendFailed') + ': ' + e.message),
})
const create = useMutation({
mutationFn: async (v: DomainFormValues) => {
const r = await apiClient.post('/domains', v)
return r.data
},
onSuccess: () => {
message.success(t('common.save'))
setCreating(false)
form.resetFields()
void qc.invalidateQueries({ queryKey: ['domains'] })
},
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => {
await apiClient.delete(`/domains/${id}`)
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['domains'] })
},
onError: (e: Error) => message.error(e.message),
})
const quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: Domain; checked: boolean }) => {
const { id: _id, created_at: _ca, updated_at: _ua, ...body } = row
await apiClient.put(`/domains/${id}`, { ...body, active: checked })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['domains'] }) },
onError: (e: Error) => message.error(e.message),
})
const columns: ColumnsType<Domain> = [
{
title: t('domains.name'), dataIndex: 'name', key: 'name',
render: (name: string, row: Domain) => (
<Space size={4} wrap>
<span>{name}</span>
{row.maintenance_mode && (
<Tooltip title={row.maintenance_message || t('domains.maintenanceTag')}>
<Tag icon={<StopOutlined />} color="orange" style={{ margin: 0 }}>
{t('domains.maintenanceTag')}
</Tag>
</Tooltip>
)}
{row.rate_limit_rps > 0 && (
<Tooltip title={`${row.rate_limit_rps} req/s`}>
<Tag icon={<ThunderboltOutlined />} color="purple" style={{ margin: 0, fontSize: 11 }}>
{t('domains.rateLimitTag')}
</Tag>
</Tooltip>
)}
</Space>
),
},
{
title: t('domains.primaryBackend'), dataIndex: 'primary_backend_id', key: 'primary_backend_id',
render: (id?: number | null) => {
if (!id) return <Tag>{t('domains.noBackend')}</Tag>
const b = backendById(id)
const health = backendHealth(id)
return (
<Space size={4}>
{b
? <Tag color="blue">{b.name} ({b.scheme})</Tag>
: <Tag color="orange">#{id}</Tag>}
{health === 'UP' && <Tag color="green" style={{ margin: 0 }}>UP</Tag>}
{health === 'DOWN' && <Tag color="red" style={{ margin: 0 }}>DOWN</Tag>}
</Space>
)
},
},
{
title: t('domains.active'), dataIndex: 'active', key: 'active', width: 80,
render: (v: boolean, row: Domain) => (
<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: t('domains.httpToHttps'), dataIndex: 'http_to_https', key: 'http_to_https', render: (v: boolean) => <StatusDot active={v} activeLabel="HTTPS" inactiveLabel="HTTP" /> },
{ title: t('domains.hsts'), dataIndex: 'hsts_enabled', key: 'hsts', render: (v: boolean) => <StatusDot active={v} /> },
{
title: t('domains.tlsCert'),
key: 'tlsCert',
width: 120,
render: (_, row) => {
const cert = certByDomain.get(row.name)
if (!cert) {
return (
<Tooltip title={t('domains.tlsCertNoneHint')}>
<Tag color="default">{t('domains.tlsCertNone')}</Tag>
</Tooltip>
)
}
if (cert.status === 'expired') {
return <Tag color="red">{t('domains.tlsCertExpired')}</Tag>
}
if (cert.status === 'error') {
return <Tag color="red">{t('domains.tlsCertError')}</Tag>
}
// Days remaining
const days = cert.not_after
? Math.round((new Date(cert.not_after).getTime() - Date.now()) / 86_400_000)
: null
if (days != null && days < 30) {
return (
<Tooltip title={cert.not_after}>
<Tag color="orange">{t('domains.tlsCertExpiring', { days })}</Tag>
</Tooltip>
)
}
return (
<Tooltip title={cert.not_after}>
<Tag color="green">{t('domains.tlsCertValid')}</Tag>
</Tooltip>
)
},
},
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<ActionButtons
onEdit={() => navigate(`/domains/${row.id}`)}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('domains.deleteConfirm', { name: row.name })}
/>
),
},
]
// openCreate: reused von extraActions-Button und EmptyState-Action.
// Setzt die Defaults für die Create-Modal-Form.
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({
active: true,
http_to_https: true,
hsts_enabled: false,
hsts_max_age: 31536000,
hsts_subdomains: false,
hsts_preload: false,
maintenance_mode: false,
maintenance_message: '',
www_redirect: '',
rate_limit_rps: 0,
max_body_kb: 0,
})
}
return (
<div>
<PageHeader
icon={<GlobalOutlined />}
title={t('domains.title')}
subtitle={t('domains.intro')}
/>
<DataTable
rowKey="id"
loading={isLoading}
dataSource={data ?? []}
columns={columns}
extraActions={
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} disabled={isViewer}>
{t('domains.addDomain')}
</Button>
</Tooltip>
}
emptyContent={
<EmptyState
icon={<GlobalOutlined />}
title={t('domains.emptyTitle')}
description={t('domains.emptyDesc')}
action={
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} disabled={isViewer}>
{t('domains.addDomain')}
</Button>
</Tooltip>
}
/>
}
/>
<Modal
title={t('domains.addDomain')}
open={creating}
onCancel={() => setCreating(false)}
onOk={() => { void form.submit() }}
confirmLoading={create.isPending}
>
<Form form={form} layout="vertical" onFinish={(v) => create.mutate(v)}>
<Form.Item label={t('domains.name')} name="name" rules={[{ required: true }]}>
<Input placeholder="example.com" />
</Form.Item>
<Form.Item label={t('domains.primaryBackend')} name="primary_backend_id"
extra={t('domains.primaryBackendHint')}>
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="primary_backend_id" noStyle>
<Select allowClear showSearch optionFilterProp="label"
placeholder={t('domains.selectBackend')}
options={(backends ?? []).filter(b => b.active).map(b => ({
value: b.id, label: b.name,
}))}
style={{ width: '100%' }}
/>
</Form.Item>
<Button onClick={() => setQuickBackendOpen(true)} title={t('domains.quickBackendBtnHint')}>
{t('domains.quickBackendBtn')}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item label={t('domains.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('domains.httpToHttps')} name="http_to_https" valuePropName="checked">
<Switch />
</Form.Item>
<Divider plain>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('domains.moreOnDetailPage')}
</Typography.Text>
</Divider>
</Form>
</Modal>
{/* Quick-Add-Backend: schmales Sub-Modal direkt aus dem Domain-
Anlegen-Flow heraus. Spart drei Navigations-Klicks (Backends-
Seite öffnen → Backend anlegen → Server anlegen → zurück). */}
<Modal
title={t('domains.quickBackendTitle')}
open={quickBackendOpen}
onCancel={() => { setQuickBackendOpen(false); quickBackendForm.resetFields() }}
onOk={() => { void quickBackendForm.submit() }}
confirmLoading={quickCreateBackend.isPending}
width={520}
>
<Form
form={quickBackendForm}
layout="vertical"
initialValues={{ scheme: 'http', port: 80 }}
onFinish={(v) => quickCreateBackend.mutate(v)}
>
<Form.Item label={t('domains.quickBackendName')} name="name" rules={[{ required: true }]}>
<Input placeholder="app1" />
</Form.Item>
<Form.Item label={t('domains.quickBackendScheme')} name="scheme">
<Select options={[
{ value: 'http', label: 'http' },
{ value: 'https', label: 'https' },
]} />
</Form.Item>
<Form.Item
label={t('domains.quickBackendAddress')}
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.10" />
</Form.Item>
<Form.Item label={t('domains.quickBackendPort')} name="port" rules={[{ required: true, type: 'number', min: 1, max: 65535 }]}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</Form>
</Modal>
</div>
)
}