From 7fab17c231830b6bc5410fae9986344c9f9b3e2f Mon Sep 17 00:00:00 2001 From: Debian Date: Wed, 20 May 2026 21:17:47 +0200 Subject: [PATCH] feat(ui): Backend-Edit als dedizierte Seite statt Modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- VERSION | 2 +- management-ui/src/App.tsx | 2 + management-ui/src/i18n/locales/de/common.json | 3 + management-ui/src/i18n/locales/en/common.json | 3 + management-ui/src/pages/Backends/Detail.tsx | 304 ++++++++++++++++++ management-ui/src/pages/Backends/index.tsx | 99 +----- 6 files changed, 329 insertions(+), 84 deletions(-) create mode 100644 management-ui/src/pages/Backends/Detail.tsx diff --git a/VERSION b/VERSION index 1027df1..b1468fd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.45 +1.1.46 diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index 28881c8..bf8a513 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 41a9628..c53fb14 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -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", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index cae1a0e..414c540 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -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", diff --git a/management-ui/src/pages/Backends/Detail.tsx b/management-ui/src/pages/Backends/Detail.tsx new file mode 100644 index 0000000..e2cf81e --- /dev/null +++ b/management-ui/src/pages/Backends/Detail.tsx @@ -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 { + 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 { + 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 { + 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() + + 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 ( +
+ } + title={backend.name} + subtitle={`${backend.scheme.toUpperCase()} · ${backend.lb_algorithm}`} + extra={ + + } + /> + + + + +
d.id), + }} + onFinish={(v) => update.mutate(v)} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + ) +} diff --git a/management-ui/src/pages/Backends/index.tsx b/management-ui/src/pages/Backends/index.tsx index b30a4c7..0642ef8 100644 --- a/management-ui/src/pages/Backends/index.tsx +++ b/management-ui/src/pages/Backends/index.tsx @@ -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 { 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(null) const [creating, setCreating] = useState(false) const [form] = Form.useForm() @@ -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) => ( { - 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() { } /> { 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 && ( - - )} +
{ - if (editing) update.mutate({ id: editing.id, v }) - else create.mutate(v) - }} + onFinish={(v) => create.mutate(v)} > @@ -368,49 +334,16 @@ export default function BackendsPage() { - - - - + ({ - value: d.id, - label: d.primary_backend_id && d.primary_backend_id !== editing?.id - ? `${d.name} (zur Zeit: #${d.primary_backend_id})` - : d.name, - }))} - /> -
- {editing && ( - - - - )}
)