Files
edgeguard-native/management-ui/src/pages/RoutingRules/index.tsx
Debian 99982ef9d1 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>
2026-05-24 08:55:22 +02:00

263 lines
9.4 KiB
TypeScript

import { 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 { 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'
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; address: string; port: number }
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'
}
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 RoutingRulesPage() {
const { t } = useTranslation()
const qc = useQueryClient()
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: listHAProxyStats,
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.address}:${b.port})` : `#${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'] })
},
})
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'] })
},
})
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 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 ? 'UP' : '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}
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
/>
),
},
{
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={rules ?? []}
columns={columns}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('routing.addRule')}
</Button>
}
emptyContent={
<EmptyState
icon={<BranchesOutlined />}
title={t('routing.emptyTitle')}
description={t('routing.emptyDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('routing.addRule')}
</Button>
}
/>
}
/>
<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}
>
<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" rules={[{ required: true }]}>
<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.address}:${b.port})` }))}
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>
)
}