feat(ui): Quick-Toggles, Config-Preview, Dashboard-Alerts, Domain-Detail-Health
Quick-Toggle-Switches (kein Modal nötig) für: Backends, Backend-Server, DNS-Zonen, DNS-Records, Domains (active), Firewall-Rules, NAT-Rules, Forward-Proxy ACLs, Routing-Rules. Dashboard: Alert-Banner für komplett ausgefallene Backends (HAProxy-Stats) und Domains im Maintenance-Mode. Domain-Detail: HAProxy-Live-Health-Badge (15s Polling), TLS-Cert ausstellen/erneuern direkt aus dem Detail, Routing-Rules-Panel inline. Config-Preview (Settings): alle 4 Generatoren (haproxy, nftables, squid, unbound) rendern via RenderToString ohne Disk-Write — GET /system/config-preview. ActionButtons: Viewer-Rolle blendet Delete aus (RBAC-Ergänzung). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,12 +50,21 @@ interface ResponseHeader {
|
||||
id: number; domain_id: number; name: string; value: string; position: number
|
||||
}
|
||||
|
||||
interface RoutingRule {
|
||||
id: number; domain_id: number; path_prefix: string
|
||||
backend_id: number; priority: number; active: boolean
|
||||
}
|
||||
|
||||
interface BackendLiteWithAddr { id: number; name: string; address: string; port: number }
|
||||
|
||||
interface TLSCertLite {
|
||||
domain: string
|
||||
status: 'pending' | 'active' | 'renewing' | 'expired' | 'error'
|
||||
not_after?: string | null
|
||||
}
|
||||
|
||||
interface HAProxyStat { backend: string; server: string; status: string }
|
||||
|
||||
async function getDomain(id: number): Promise<Domain | null> {
|
||||
const r = await apiClient.get(`/domains/${id}`)
|
||||
if (!isEnvelope(r.data)) return null
|
||||
@@ -71,11 +80,28 @@ async function listHeaders(domainID: number): Promise<ResponseHeader[]> {
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { headers?: ResponseHeader[] }).headers ?? []
|
||||
}
|
||||
async function listDomainRules(domainID: number): Promise<RoutingRule[]> {
|
||||
const r = await apiClient.get(`/domains/${domainID}/routing-rules`)
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { routing_rules?: RoutingRule[] }).routing_rules ?? []
|
||||
}
|
||||
async function listBackendsWithAddr(): Promise<BackendLiteWithAddr[]> {
|
||||
const r = await apiClient.get('/backends')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { backends?: BackendLiteWithAddr[] }).backends ?? []
|
||||
}
|
||||
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 ?? []
|
||||
}
|
||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
||||
try {
|
||||
const r = await apiClient.get('/haproxy/stats')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
export default function DomainDetailPage() {
|
||||
const { t } = useTranslation()
|
||||
@@ -91,6 +117,11 @@ export default function DomainDetailPage() {
|
||||
})
|
||||
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
|
||||
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
||||
const { data: haproxyStats } = useQuery({
|
||||
queryKey: ['haproxy', 'stats'],
|
||||
queryFn: listHAProxyStats,
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
|
||||
const [form] = Form.useForm<DomainFormValues>()
|
||||
|
||||
@@ -106,10 +137,30 @@ export default function DomainDetailPage() {
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const issueCert = useMutation({
|
||||
mutationFn: async (domainName: string) => {
|
||||
await apiClient.post('/tls-certs/issue', { domain: domainName })
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(t('ssl.issueSuccess'))
|
||||
void qc.invalidateQueries({ queryKey: ['tls-certs'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
if (isLoading || !domain) return null
|
||||
|
||||
const cert = certs?.find(c => c.domain === domain.name)
|
||||
|
||||
// Primary backend live health from HAProxy stats — UP/DOWN/null
|
||||
const backendHealth = (() => {
|
||||
if (!domain.primary_backend_id || !haproxyStats?.length) return null
|
||||
const name = `eg_backend_${domain.primary_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 certBadge = () => {
|
||||
if (!cert) return <Tag icon={<LockOutlined />} color="default">{t('domains.tlsCertNone')}</Tag>
|
||||
if (cert.status === 'expired') return <Tag icon={<LockOutlined />} color="red">{t('domains.tlsCertExpired')}</Tag>
|
||||
@@ -138,14 +189,53 @@ export default function DomainDetailPage() {
|
||||
<PageHeader
|
||||
icon={<GlobalOutlined />}
|
||||
title={domain.name}
|
||||
subtitle={<Space size={6}><StatusDot active={domain.active} />{certBadge()}</Space>}
|
||||
subtitle={
|
||||
<Space size={6}>
|
||||
<StatusDot active={domain.active} />
|
||||
{certBadge()}
|
||||
{backendHealth === 'UP' && <Tag color="green" style={{ margin: 0 }}>backend UP</Tag>}
|
||||
{backendHealth === 'DOWN' && <Tag color="red" style={{ margin: 0 }}>backend DOWN</Tag>}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/domains')}>
|
||||
{t('domains.backToList')}
|
||||
</Button>
|
||||
<Space>
|
||||
{cert ? (
|
||||
<Popconfirm
|
||||
title={t('ssl.renewConfirmTitle')}
|
||||
description={t('ssl.renewConfirmDesc', { domain: domain.name })}
|
||||
onConfirm={() => issueCert.mutate(domain.name)}
|
||||
okText={t('common.yes')} cancelText={t('common.no')}
|
||||
>
|
||||
<Button
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
loading={issueCert.isPending}
|
||||
>
|
||||
{t('ssl.renewBtn')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Button
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
type="primary"
|
||||
loading={issueCert.isPending}
|
||||
onClick={() => issueCert.mutate(domain.name)}
|
||||
>
|
||||
{t('ssl.issueButton')}
|
||||
</Button>
|
||||
)}
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/domains')}>
|
||||
{t('domains.backToList')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<Row gutter={[24, 0]}>
|
||||
<Col xs={24}>
|
||||
<RoutingRulesPanel domainID={domainID} domainName={domain.name} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card size="small" title={t('domains.settingsCard')} className="mb-16">
|
||||
@@ -273,6 +363,145 @@ export default function DomainDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Routing Rules Panel ─────────────────────────────────────────────────
|
||||
|
||||
function RoutingRulesPanel({ domainID, domainName }: { domainID: number; domainName: string }) {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<RoutingRule | null>(null)
|
||||
const [rForm] = Form.useForm<{ path_prefix: string; backend_id: number; priority: number; active: boolean }>()
|
||||
|
||||
const { data: rules, isLoading } = useQuery({
|
||||
queryKey: ['domain-routing-rules', domainID],
|
||||
queryFn: () => listDomainRules(domainID),
|
||||
})
|
||||
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackendsWithAddr })
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['domain-routing-rules', domainID] })
|
||||
void qc.invalidateQueries({ queryKey: ['routing-rules'] })
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async (v: { path_prefix: string; backend_id: number; priority: number; active: boolean }) =>
|
||||
apiClient.post('/routing-rules', { ...v, domain_id: domainID }),
|
||||
onSuccess: () => {
|
||||
message.success(t('common.save'))
|
||||
setOpen(false); rForm.resetFields(); invalidate()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const update = useMutation({
|
||||
mutationFn: async ({ id, v }: { id: number; v: { path_prefix: string; backend_id: number; priority: number; active: boolean } }) =>
|
||||
apiClient.put(`/routing-rules/${id}`, { ...v, domain_id: domainID }),
|
||||
onSuccess: () => {
|
||||
message.success(t('common.save'))
|
||||
setEditing(null); rForm.resetFields(); invalidate()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.delete(`/routing-rules/${id}`),
|
||||
onSuccess: invalidate,
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const backendLabel = (id: number) => {
|
||||
const b = backends?.find(x => x.id === id)
|
||||
return b ? `${b.name} (${b.address}:${b.port})` : `#${id}`
|
||||
}
|
||||
|
||||
const columns: ColumnsType<RoutingRule> = [
|
||||
{ title: t('routing.pathPrefix'), dataIndex: 'path_prefix', key: 'path' },
|
||||
{ title: t('routing.backend'), dataIndex: 'backend_id', key: 'backend', render: (id: number) => backendLabel(id) },
|
||||
{ title: t('routing.priority'), dataIndex: 'priority', key: 'priority', width: 90 },
|
||||
{
|
||||
title: t('routing.active'), dataIndex: 'active', key: 'active', width: 80,
|
||||
render: (v: boolean) => <StatusDot active={v} />,
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'a', width: 90,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Button type="text" size="small" icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(r)
|
||||
rForm.setFieldsValue({ path_prefix: r.path_prefix, backend_id: r.backend_id, priority: r.priority, active: r.active })
|
||||
}}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('routing.deleteConfirm')}
|
||||
okText={t('common.yes')} cancelText={t('common.no')}
|
||||
onConfirm={() => del.mutate(r.id)}
|
||||
>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
size="small"
|
||||
title={t('domains.routingRulesTitle', { name: domainName })}
|
||||
className="mb-16"
|
||||
extra={
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => {
|
||||
setOpen(true); rForm.resetFields()
|
||||
rForm.setFieldsValue({ path_prefix: '/', priority: 100, active: true })
|
||||
}}>
|
||||
{t('routing.addRule')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
|
||||
{t('domains.routingRulesHint')}
|
||||
</Text>
|
||||
<Table
|
||||
rowKey="id" size="small" loading={isLoading}
|
||||
dataSource={rules ?? []} columns={columns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('domains.routingRulesEmpty') }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? t('routing.editRule') : t('routing.addRule')}
|
||||
open={open || editing !== null}
|
||||
onCancel={() => { setOpen(false); setEditing(null); rForm.resetFields() }}
|
||||
onOk={() => { void rForm.submit() }}
|
||||
confirmLoading={create.isPending || update.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={rForm} layout="vertical"
|
||||
onFinish={(v) => editing ? update.mutate({ id: editing.id, v }) : create.mutate(v)}
|
||||
>
|
||||
<Form.Item label={t('routing.pathPrefix')} name="path_prefix" rules={[{ required: true }]}>
|
||||
<Input placeholder="/" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('routing.backend')} name="backend_id" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch optionFilterProp="label"
|
||||
placeholder={t('routing.selectBackend')}
|
||||
options={(backends ?? []).map(b => ({ value: b.id, label: `${b.name} (${b.address}:${b.port})` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('routing.priority')} name="priority" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('routing.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Response Headers Panel ──────────────────────────────────────────────
|
||||
|
||||
function HeadersPanel({ domainID, domainName }: { domainID: number; domainName: string }) {
|
||||
|
||||
Reference in New Issue
Block a user