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>
263 lines
9.4 KiB
TypeScript
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>
|
|
)
|
|
}
|