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:
@@ -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 />} />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
304
management-ui/src/pages/Backends/Detail.tsx
Normal file
304
management-ui/src/pages/Backends/Detail.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user