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>
451 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|