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>
411 lines
16 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|