feat(ui): Backend-Edit als dedizierte Seite statt Modal

/backends/:id zeigt Pool-Einstellungen (links) und Server-Liste
(rechts) auf einer eigenen Seite. Der Edit-Button in der Liste
navigiert dorthin; Anlegen bleibt ein schlankes Modal ohne Server-
Panel. Außerdem: Backend-Detail lädt den Pool per GET /backends/:id
und speichert alle Felder direkt, keine Sync-Logik nötig.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-20 21:17:47 +02:00
parent bd6e67f059
commit 7fab17c231
6 changed files with 329 additions and 84 deletions

View File

@@ -1 +1 @@
1.1.45
1.1.46

View File

@@ -16,6 +16,7 @@ const SetupPage = lazy(() => import('./pages/Setup'))
const DashboardPage = lazy(() => import('./pages/Dashboard'))
const DomainsPage = lazy(() => import('./pages/Domains'))
const BackendsPage = lazy(() => import('./pages/Backends'))
const BackendDetailPage = lazy(() => import('./pages/Backends/Detail'))
const RoutingRulesPage = lazy(() => import('./pages/RoutingRules'))
const NetworksPage = lazy(() => import('./pages/Networks'))
const IPAddressesPage = lazy(() => import('./pages/IPAddresses'))
@@ -107,6 +108,7 @@ export default function App() {
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/domains" element={<DomainsPage />} />
<Route path="/backends" element={<BackendsPage />} />
<Route path="/backends/:id" element={<BackendDetailPage />} />
<Route path="/routing-rules" element={<RoutingRulesPage />} />
<Route path="/networks" element={<NetworksPage />} />
<Route path="/ip-addresses" element={<IPAddressesPage />} />

View File

@@ -334,6 +334,9 @@
"serverHintCreate": "Speichern legt nur den Pool an. Server kommen im nächsten Schritt — Pool öffnen → „Server hinzufügen\".",
"actions": "Aktionen",
"deleteConfirm": "Backend-Pool {{name}} wirklich löschen? Alle Server im Pool werden mitentfernt.",
"backToList": "Zurück zur Übersicht",
"settingsCard": "Pool-Einstellungen",
"healthCheckHint": "HTTP-Pfad den HAProxy alle 5s pro Server prüft. Leer lassen = kein Health-Check.",
"server": {
"intro": "Upstream-Server im Pool. Reihenfolge in HAProxy egal — der LB-Algorithmus entscheidet.",
"add": "Server hinzufügen",

View File

@@ -334,6 +334,9 @@
"serverHintCreate": "Saving creates the pool only. Add servers in the next step — open the pool and click “Add server”.",
"actions": "Actions",
"deleteConfirm": "Really delete backend pool {{name}}? All servers in the pool will be removed too.",
"backToList": "Back to backends",
"settingsCard": "Pool settings",
"healthCheckHint": "HTTP path HAProxy probes every 5s per server. Leave empty to disable health checks.",
"server": {
"intro": "Upstream servers in this pool. Order doesn't matter — the LB algorithm decides.",
"add": "Add server",

View File

@@ -0,0 +1,304 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Button, Card, Col, Form, Input, InputNumber, Modal,
Row, Select, Switch, Table, Tag, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ArrowLeftOutlined, DatabaseOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
const { Text } = Typography
interface Backend {
id: number; name: string; scheme: string
health_check_path?: string | null
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
websocket: boolean; active: boolean
}
interface BackendFormValues {
name: string; scheme: 'http' | 'https'
health_check_path?: string
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
websocket: boolean; active: boolean
domain_ids?: number[]
}
interface BackendServer {
id: number; backend_id: number; name: string
address: string; port: number
weight: number; backup: boolean; active: boolean
}
interface ServerFormValues {
name: string; address: string; port: number
weight: number; backup: boolean; active: boolean
}
interface DomainFull { id: number; name: string; primary_backend_id?: number | null }
async function getBackend(id: number): Promise<Backend | null> {
const r = await apiClient.get(`/backends/${id}`)
if (!isEnvelope(r.data)) return null
return r.data.data as Backend
}
async function listServers(backendID: number): Promise<BackendServer[]> {
const r = await apiClient.get(`/backends/${backendID}/servers`)
if (!isEnvelope(r.data)) return []
return (r.data.data as { servers?: BackendServer[] }).servers ?? []
}
async function listDomains(): Promise<DomainFull[]> {
const r = await apiClient.get('/domains')
if (!isEnvelope(r.data)) return []
return (r.data.data as { domains?: DomainFull[] }).domains ?? []
}
export default function BackendDetailPage() {
const { t } = useTranslation()
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const backendID = Number(id)
const { data: backend, isLoading } = useQuery({
queryKey: ['backend', backendID],
queryFn: () => getBackend(backendID),
enabled: !isNaN(backendID),
})
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
const [form] = Form.useForm<BackendFormValues>()
const update = useMutation({
mutationFn: async (v: BackendFormValues) => { await apiClient.put(`/backends/${backendID}`, v) },
onSuccess: () => {
message.success(t('common.save'))
void qc.invalidateQueries({ queryKey: ['backends'] })
void qc.invalidateQueries({ queryKey: ['backend', backendID] })
},
onError: (e: Error) => message.error(e.message),
})
if (isLoading || !backend) return null
const attached = (domains ?? []).filter(d => d.primary_backend_id === backendID)
return (
<div>
<PageHeader
icon={<DatabaseOutlined />}
title={backend.name}
subtitle={`${backend.scheme.toUpperCase()} · ${backend.lb_algorithm}`}
extra={
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/backends')}>
{t('backends.backToList')}
</Button>
}
/>
<Row gutter={24}>
<Col xs={24} lg={10}>
<Card size="small" title={t('backends.settingsCard')} className="mb-16">
<Form
form={form}
layout="vertical"
initialValues={{
name: backend.name,
scheme: backend.scheme,
health_check_path: backend.health_check_path ?? undefined,
lb_algorithm: backend.lb_algorithm,
websocket: backend.websocket,
active: backend.active,
domain_ids: attached.map(d => d.id),
}}
onFinish={(v) => update.mutate(v)}
>
<Form.Item label={t('backends.name')} name="name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label={t('backends.scheme')} name="scheme" rules={[{ required: true }]}>
<Select options={[{ value: 'http', label: 'http' }, { value: 'https', label: 'https' }]} />
</Form.Item>
<Form.Item label={t('backends.lbAlgo')} name="lb_algorithm" rules={[{ required: true }]}
extra={t('backends.lbAlgoHint')}>
<Select options={[
{ value: 'roundrobin', label: 'roundrobin — gleichmäßige Verteilung' },
{ value: 'leastconn', label: 'leastconn — wenigste aktive Verbindungen' },
{ value: 'source', label: 'source — sticky per Source-IP-Hash' },
]} />
</Form.Item>
<Form.Item label={t('backends.healthCheck')} name="health_check_path"
extra={t('backends.healthCheckHint')}>
<Input placeholder="/health" allowClear />
</Form.Item>
<Form.Item label={t('backends.websocket')} name="websocket" valuePropName="checked"
extra={t('backends.websocketHint')}>
<Switch />
</Form.Item>
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('backends.attachedDomains')} name="domain_ids"
extra={t('backends.attachedDomainsHint')}>
<Select
mode="multiple" allowClear showSearch optionFilterProp="label"
placeholder={t('backends.selectDomains')}
options={(domains ?? []).map(d => ({
value: d.id,
label: d.primary_backend_id && d.primary_backend_id !== backendID
? `${d.name} (derzeit: #${d.primary_backend_id})`
: d.name,
}))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={update.isPending}>
{t('common.save')}
</Button>
</Form.Item>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title={t('backends.serversIn', { name: backend.name })}>
<ServerPanel backendID={backendID} />
</Card>
</Col>
</Row>
</div>
)
}
function ServerPanel({ backendID }: { backendID: number }) {
const { t } = useTranslation()
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<BackendServer | null>(null)
const [form] = Form.useForm<ServerFormValues>()
const { data: servers, isLoading } = useQuery({
queryKey: ['backend-servers', backendID],
queryFn: () => listServers(backendID),
})
const create = useMutation({
mutationFn: async (v: ServerFormValues) => {
await apiClient.post(`/backends/${backendID}/servers`, v)
},
onSuccess: () => {
message.success(t('common.save'))
setOpen(false); form.resetFields()
void qc.invalidateQueries({ queryKey: ['backend-servers', backendID] })
void qc.invalidateQueries({ queryKey: ['backend-server-counts'] })
},
onError: (e: Error) => message.error(e.message),
})
const update = useMutation({
mutationFn: async ({ id, v }: { id: number; v: ServerFormValues }) => {
await apiClient.put(`/backend-servers/${id}`, { ...v, backend_id: backendID })
},
onSuccess: () => {
message.success(t('common.save'))
setEditing(null); form.resetFields()
void qc.invalidateQueries({ queryKey: ['backend-servers', backendID] })
},
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => { await apiClient.delete(`/backend-servers/${id}`) },
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['backend-servers', backendID] })
void qc.invalidateQueries({ queryKey: ['backend-server-counts'] })
},
onError: (e: Error) => message.error(e.message),
})
const cols: ColumnsType<BackendServer> = [
{
title: t('backends.server.name'), dataIndex: 'name',
render: (s: string) => <code>{s}</code>,
},
{
title: t('backends.server.target'), key: 'tgt',
render: (_, r) => <Text code>{r.address}:{r.port}</Text>,
},
{ title: t('backends.server.weight'), dataIndex: 'weight', width: 80 },
{
title: t('backends.server.backup'), dataIndex: 'backup', width: 90,
render: (v: boolean) => v ? <Tag color="purple">Backup</Tag> : '—',
},
{
title: t('backends.active'), dataIndex: 'active', width: 80,
render: (v: boolean) => <StatusDot active={v} />,
},
{
title: t('common.actions'), key: 'a', width: 100,
render: (_, r) => (
<ActionButtons
onEdit={() => {
setEditing(r)
form.setFieldsValue({
name: r.name, address: r.address, port: r.port,
weight: r.weight, backup: r.backup, active: r.active,
})
}}
onDelete={() => del.mutate(r.id)}
deleteConfirm={t('backends.server.deleteConfirm', { name: r.name })}
/>
),
},
]
return (
<>
<div className="mb-8" style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<PlusOutlined />} size="small" onClick={() => {
setOpen(true); form.resetFields()
form.setFieldsValue({ weight: 100, backup: false, active: true, port: 8080 })
}}>
{t('backends.server.add')}
</Button>
</div>
<Table
size="small" rowKey="id" loading={isLoading}
dataSource={servers ?? []} columns={cols}
pagination={false}
locale={{ emptyText: t('backends.server.empty') }}
/>
<Modal
title={editing ? t('backends.server.edit') : t('backends.server.add')}
open={open || editing !== null}
onCancel={() => { setOpen(false); setEditing(null); form.resetFields() }}
onOk={() => { void form.submit() }}
confirmLoading={create.isPending || update.isPending}
destroyOnHidden
>
<Form form={form} layout="vertical"
onFinish={(v) => editing ? update.mutate({ id: editing.id, v }) : create.mutate(v)}>
<Form.Item label={t('backends.server.name')} name="name" rules={[{ required: true }]}>
<Input placeholder="vmm-1" />
</Form.Item>
<Form.Item label={t('backends.server.address')} name="address" rules={[{ required: true }]}>
<Input placeholder="10.0.0.11" />
</Form.Item>
<Form.Item label={t('backends.server.port')} name="port" rules={[{ required: true }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('backends.server.weight')} name="weight" extra={t('backends.server.weightHint')}>
<InputNumber min={0} max={256} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('backends.server.backup')} name="backup" valuePropName="checked"
extra={t('backends.server.backupHint')}>
<Switch />
</Form.Item>
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</>
)
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Alert, Button, Card, Form, Input, InputNumber, Modal, Popconfirm,
Alert, Button, Form, Input, InputNumber, Modal, Popconfirm,
Select, Space, Switch, Table, Tag, Tooltip, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
@@ -107,6 +108,7 @@ async function listHAProxyStats(): Promise<HAProxyStat[]> {
export default function BackendsPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const qc = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
@@ -155,7 +157,6 @@ export default function BackendsPage() {
const domainsForBackend = (id: number) =>
(domains ?? []).filter(d => d.primary_backend_id === id)
const [editing, setEditing] = useState<Backend | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<BackendFormValues>()
@@ -207,21 +208,6 @@ export default function BackendsPage() {
},
})
const update = useMutation({
mutationFn: async ({ id, v }: { id: number; v: BackendFormValues }) => {
const { domain_ids, ...body } = v
await apiClient.put(`/backends/${id}`, body)
await syncDomainAttachments(id, domain_ids ?? [])
},
onSuccess: () => {
message.success(t('common.save'))
setEditing(null)
form.resetFields()
void qc.invalidateQueries({ queryKey: ['backends'] })
void qc.invalidateQueries({ queryKey: ['domains'] })
},
})
const del = useMutation({
mutationFn: async (id: number) => { await apiClient.delete(`/backends/${id}`) },
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['backends'] }) },
@@ -279,18 +265,7 @@ export default function BackendsPage() {
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<ActionButtons
onEdit={() => {
setEditing(row)
form.setFieldsValue({
name: row.name,
scheme: row.scheme as 'http' | 'https',
health_check_path: row.health_check_path ?? undefined,
lb_algorithm: row.lb_algorithm,
websocket: row.websocket,
active: row.active,
domain_ids: domainsForBackend(row.id).map(d => d.id),
})
}}
onEdit={() => navigate(`/backends/${row.id}`)}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('backends.deleteConfirm', { name: row.name })}
/>
@@ -338,27 +313,18 @@ export default function BackendsPage() {
}
/>
<Modal
title={editing ? t('backends.editBackend') : t('backends.addBackend')}
open={editing !== null || creating}
onCancel={() => { setEditing(null); setCreating(false) }}
title={t('backends.addBackend')}
open={creating}
onCancel={() => { setCreating(false); form.resetFields() }}
onOk={() => { void form.submit() }}
confirmLoading={create.isPending || update.isPending}
width={640}
confirmLoading={create.isPending}
width={560}
>
{creating && (
<Alert
type="info" showIcon
message={t('backends.serverHintCreate')}
className="mb-16"
/>
)}
<Alert type="info" showIcon message={t('backends.serverHintCreate')} className="mb-16" />
<Form
form={form}
layout="vertical"
onFinish={(v) => {
if (editing) update.mutate({ id: editing.id, v })
else create.mutate(v)
}}
onFinish={(v) => create.mutate(v)}
>
<Form.Item label={t('backends.name')} name="name" rules={[{ required: true }]}>
<Input placeholder="vmm-pool" />
@@ -368,49 +334,16 @@ export default function BackendsPage() {
</Form.Item>
<Form.Item label={t('backends.lbAlgo')} name="lb_algorithm" rules={[{ required: true }]}
extra={t('backends.lbAlgoHint')}>
<Select
options={[
{ value: 'roundrobin', label: 'roundrobin — gleichmäßige Verteilung' },
{ value: 'leastconn', label: 'leastconn — wenigste aktive Verbindungen' },
{ value: 'source', label: 'source — sticky per Source-IP-Hash' },
]}
/>
</Form.Item>
<Form.Item label={t('backends.healthCheck')} name="health_check_path">
<Input placeholder="/health" />
</Form.Item>
<Form.Item label={t('backends.websocket')} name="websocket" valuePropName="checked"
extra={t('backends.websocketHint')}>
<Switch />
<Select options={[
{ value: 'roundrobin', label: 'roundrobin — gleichmäßige Verteilung' },
{ value: 'leastconn', label: 'leastconn — wenigste aktive Verbindungen' },
{ value: 'source', label: 'source — sticky per Source-IP-Hash' },
]} />
</Form.Item>
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
label={t('backends.attachedDomains')}
name="domain_ids"
extra={t('backends.attachedDomainsHint')}
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder={t('backends.selectDomains')}
options={(domains ?? []).map(d => ({
value: d.id,
label: d.primary_backend_id && d.primary_backend_id !== editing?.id
? `${d.name} (zur Zeit: #${d.primary_backend_id})`
: d.name,
}))}
/>
</Form.Item>
</Form>
{editing && (
<Card size="small" title={t('backends.serversIn', { name: editing.name })} className="mt-16">
<ServerPanel backendID={editing.id} />
</Card>
)}
</Modal>
</div>
)