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 { 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 ?? [] } 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 { 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() 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[] = [] 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 (
} title={backend.name} subtitle={`${backend.scheme.toUpperCase()} · ${backend.lb_algorithm}`} extra={ } />
d.id), }} onFinish={(v) => update.mutate(v)} >
) }