Files
edgeguard-native/management-ui/src/pages/Backends/Detail.tsx
Debian d87068535d fix(backends): disabled={isViewer} fehlt am Server-Active-Switch in Detail.tsx
Der ServerPanel in Backends/Detail.tsx hat den isViewer-Prop korrekt
erhalten, ihn aber nicht auf den Active-Switch angewendet. Viewer
konnten dadurch den Server-Status im Backend-Detail direkt umschalten
(backends/index.tsx hatte den Switch korrekt gesperrt, Detail.tsx nicht).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:15:02 +02:00

411 lines
16 KiB
TypeScript

import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Button, Card, Col, Form, Input, InputNumber, Modal,
Row, Select, Switch, Table, Tag, Tooltip, 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 { useAuthStore } from '../../stores/auth'
const { Text } = Typography
interface Backend {
id: number; name: string; scheme: string
health_check_path?: string | null
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
websocket: boolean; force_http1: boolean; active: boolean
}
interface BackendFormValues {
name: string; scheme: 'http' | 'https'
health_check_path?: string
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
websocket: boolean; force_http1: 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; active: boolean
primary_backend_id?: number | null
http_to_https: boolean; hsts_enabled: boolean
hsts_max_age: number; hsts_subdomains: boolean; hsts_preload: boolean
maintenance_mode: boolean; maintenance_message?: string | null
www_redirect: string; rate_limit_rps: number
max_body_kb: number; disable_h3: boolean; notes?: string | 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 ?? []
}
interface HAProxyStat {
backend: string; server: string; status: string
sessions: number; bytes_in: number; bytes_out: number
req_tot: number; req_rate: number
last_change_sec: number; health: string
}
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 [] }
}
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'
}
// Mirror of haproxy.go safeID: replaces any char outside [a-zA-Z0-9_-] with '_'.
// HAProxy uses this to generate server tokens; we need it to match stat rows.
function safeID(s: string): string {
const out = s.replace(/[^a-zA-Z0-9_-]/g, '_')
return out || 'unnamed'
}
export default function BackendDetailPage() {
const { t } = useTranslation()
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const backendID = Number(id)
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const { data: backend, isLoading } = useQuery({
queryKey: ['backend', backendID],
queryFn: () => getBackend(backendID),
enabled: !isNaN(backendID),
})
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'],
queryFn: listHAProxyStats,
refetchInterval: 10_000,
})
const [form] = Form.useForm<BackendFormValues>()
async function syncDomainAttachments(selected: number[]) {
const all = domains ?? []
const wasAttached = new Set(all.filter(d => d.primary_backend_id === backendID).map(d => d.id))
const want = new Set(selected)
const puts: Promise<unknown>[] = []
for (const id of [...want].filter(id => !wasAttached.has(id))) {
const d = all.find(x => x.id === id)
if (d) puts.push(apiClient.put(`/domains/${id}`, { ...d, primary_backend_id: backendID }))
}
for (const id of [...wasAttached].filter(id => !want.has(id))) {
const d = all.find(x => x.id === id)
if (d) puts.push(apiClient.put(`/domains/${id}`, { ...d, primary_backend_id: null }))
}
if (puts.length) await Promise.all(puts)
}
const update = useMutation({
mutationFn: async (v: BackendFormValues) => {
const { domain_ids, ...body } = v
await apiClient.put(`/backends/${backendID}`, body)
await syncDomainAttachments(domain_ids ?? [])
},
onSuccess: () => {
message.success(t('common.save'))
void qc.invalidateQueries({ queryKey: ['backends'] })
void qc.invalidateQueries({ queryKey: ['backend', backendID] })
void qc.invalidateQueries({ queryKey: ['domains'] })
},
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,
force_http1: backend.force_http1,
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' },
{ value: 'leastconn', label: 'leastconn' },
{ value: 'source', label: 'source' },
]} />
</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.forceHttp1')} name="force_http1" valuePropName="checked"
extra={t('backends.forceHttp1Hint')}>
<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} (${t('backends.attachedToOther', { id: d.primary_backend_id })})`
: d.name,
}))}
/>
</Form.Item>
<Form.Item>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" htmlType="submit" loading={update.isPending} disabled={isViewer}>
{t('common.save')}
</Button>
</Tooltip>
</Form.Item>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title={t('backends.serversIn', { name: backend.name })}>
<ServerPanel backendID={backendID} haproxyStats={haproxyStats ?? []} isViewer={isViewer} />
</Card>
</Col>
</Row>
</div>
)
}
function ServerPanel({ backendID, haproxyStats, isViewer }: { backendID: number; haproxyStats: HAProxyStat[]; isViewer: boolean }) {
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 quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: BackendServer; checked: boolean }) => {
const { id: _id, ...body } = row
await apiClient.put(`/backend-servers/${id}`, { ...body, active: checked })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['backend-servers', backendID] }) },
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, r: BackendServer) => (
<Switch
size="small"
checked={v}
disabled={isViewer}
loading={quickToggle.isPending && quickToggle.variables?.id === r.id}
onChange={(checked) => quickToggle.mutate({ id: r.id, row: r, checked })}
/>
),
},
{
title: t('backends.server.live'), key: 'live', width: 160,
render: (_, r) => {
const stat = haproxyStats.find(
s => s.backend === `eg_backend_${backendID}` && s.server === safeID(r.name)
)
if (!stat) return <Text type="secondary" style={{ fontSize: 11 }}></Text>
const color = stat.status === 'UP' ? 'green' : stat.status === 'no check' ? 'default' : 'red'
return (
<Tooltip title={`${fmtBytes(stat.bytes_in)}${fmtBytes(stat.bytes_out)} · ${stat.req_tot} req total · ${stat.health || stat.status}`}>
<Tag color={color} style={{ margin: 0, fontSize: 11 }}>{stat.status}</Tag>
<Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>
{stat.sessions} sess{stat.req_rate > 0 ? ` · ${stat.req_rate}/s` : ''}
</Text>
</Tooltip>
)
},
},
{
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' }}>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" icon={<PlusOutlined />} size="small" disabled={isViewer} onClick={() => {
setOpen(true); form.resetFields()
form.setFieldsValue({ weight: 100, backup: false, active: true, port: 8080 })
}}>
{t('backends.server.add')}
</Button>
</Tooltip>
</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>
</>
)
}