import { useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { Button, Card, Col, Divider, Form, Input, InputNumber, Modal, Popconfirm, Row, Select, Space, Switch, Table, Tag, Tooltip, Typography, message, } from 'antd' import type { ColumnsType } from 'antd/es/table' import { ArrowLeftOutlined, DeleteOutlined, EditOutlined, GlobalOutlined, LockOutlined, PlusOutlined, SafetyCertificateOutlined, } 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 StatusDot from '../../components/StatusDot' import { useAuthStore } from '../../stores/auth' const { Text } = Typography interface Domain { 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: '' | 'to-naked' | 'to-www' rate_limit_rps: number; max_body_kb: number disable_h3: boolean notes?: string | null } interface DomainFormValues { 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 www_redirect: '' | 'to-naked' | 'to-www' rate_limit_rps: number; max_body_kb: number disable_h3: boolean notes?: string } interface BackendLite { id: number; name: string; active: boolean } interface ResponseHeader { id: number; domain_id: number; name: string; value: string; position: number } interface RoutingRule { id: number; domain_id: number; path_prefix: string backend_id: number; priority: number; active: boolean } interface BackendLiteWithAddr { id: number; name: string; address: string; port: number } interface TLSCertLite { domain: string status: 'pending' | 'active' | 'renewing' | 'expired' | 'error' not_after?: string | null } interface HAProxyStat { backend: string; server: string; status: string } async function getDomain(id: number): Promise { const r = await apiClient.get(`/domains/${id}`) if (!isEnvelope(r.data)) return null return r.data.data as Domain } async function listBackends(): Promise { const r = await apiClient.get('/backends') if (!isEnvelope(r.data)) return [] return (r.data.data as { backends?: BackendLite[] }).backends ?? [] } async function listHeaders(domainID: number): Promise { const r = await apiClient.get(`/domains/${domainID}/headers`) if (!isEnvelope(r.data)) return [] return (r.data.data as { headers?: ResponseHeader[] }).headers ?? [] } async function listDomainRules(domainID: number): Promise { const r = await apiClient.get(`/domains/${domainID}/routing-rules`) if (!isEnvelope(r.data)) return [] return (r.data.data as { routing_rules?: RoutingRule[] }).routing_rules ?? [] } async function listBackendsWithAddr(): Promise { const r = await apiClient.get('/backends') if (!isEnvelope(r.data)) return [] return (r.data.data as { backends?: BackendLiteWithAddr[] }).backends ?? [] } async function listCerts(): Promise { const r = await apiClient.get('/tls-certs') if (!isEnvelope(r.data)) return [] return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? [] } 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 [] } } export default function DomainDetailPage() { const { t } = useTranslation() const { id } = useParams<{ id: string }>() const navigate = useNavigate() const qc = useQueryClient() const domainID = Number(id) const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const { data: domain, isLoading } = useQuery({ queryKey: ['domain', domainID], queryFn: () => getDomain(domainID), enabled: !isNaN(domainID), }) const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends }) const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts }) const { data: haproxyStats } = useQuery({ queryKey: ['haproxy', 'stats'], queryFn: listHAProxyStats, refetchInterval: 15_000, }) const [form] = Form.useForm() const update = useMutation({ mutationFn: async (v: DomainFormValues) => { await apiClient.put(`/domains/${domainID}`, v) }, onSuccess: () => { message.success(t('common.save')) void qc.invalidateQueries({ queryKey: ['domains'] }) void qc.invalidateQueries({ queryKey: ['domain', domainID] }) }, onError: (e: Error) => message.error(e.message), }) const issueCert = useMutation({ mutationFn: async (domainName: string) => { await apiClient.post('/tls-certs/issue', { domain: domainName }) }, onSuccess: () => { message.success(t('ssl.issueSuccess')) void qc.invalidateQueries({ queryKey: ['tls-certs'] }) }, onError: (e: Error) => message.error(e.message), }) if (isLoading || !domain) return null const cert = certs?.find(c => c.domain === domain.name) // Primary backend live health from HAProxy stats — UP/DOWN/null const backendHealth = (() => { if (!domain.primary_backend_id || !haproxyStats?.length) return null const name = `eg_backend_${domain.primary_backend_id}` const servers = haproxyStats.filter(s => s.backend === name) if (!servers.length) return null return servers.some(s => s.status === 'UP') ? 'UP' : 'DOWN' })() const certBadge = () => { if (!cert) return } color="default">{t('domains.tlsCertNone')} if (cert.status === 'expired') return } color="red">{t('domains.tlsCertExpired')} if (cert.status === 'error') return } color="red">{t('domains.tlsCertError')} const days = cert.not_after ? Math.round((new Date(cert.not_after).getTime() - Date.now()) / 86_400_000) : null if (days != null && days < 30) { return ( } color="orange"> {t('domains.tlsCertExpiring', { days })} ) } return ( } color="green">{t('domains.tlsCertValid')} ) } return (
} title={domain.name} subtitle={ {certBadge()} {backendHealth === 'UP' && {t('domains.backendUp')}} {backendHealth === 'DOWN' && {t('domains.backendDown')}} } extra={ {cert ? ( issueCert.mutate(domain.name)} okText={t('common.yes')} cancelText={t('common.no')} disabled={isViewer} > ) : ( )} } />
update.mutate(v)} > p.maintenance_mode !== c.maintenance_mode}> {({ getFieldValue }) => getFieldValue('maintenance_mode') ? ( ) : null}
) } // ── Routing Rules Panel ───────────────────────────────────────────────── function RoutingRulesPanel({ domainID, domainName, isViewer }: { domainID: number; domainName: string; isViewer: boolean }) { const { t } = useTranslation() const qc = useQueryClient() const [open, setOpen] = useState(false) const [editing, setEditing] = useState(null) const [rForm] = Form.useForm<{ path_prefix: string; backend_id: number; priority: number; active: boolean }>() const { data: rules, isLoading } = useQuery({ queryKey: ['domain-routing-rules', domainID], queryFn: () => listDomainRules(domainID), }) const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackendsWithAddr }) const invalidate = () => { void qc.invalidateQueries({ queryKey: ['domain-routing-rules', domainID] }) void qc.invalidateQueries({ queryKey: ['routing-rules'] }) } const create = useMutation({ mutationFn: async (v: { path_prefix: string; backend_id: number; priority: number; active: boolean }) => apiClient.post('/routing-rules', { ...v, domain_id: domainID }), onSuccess: () => { message.success(t('common.save')) setOpen(false); rForm.resetFields(); invalidate() }, onError: (e: Error) => message.error(e.message), }) const update = useMutation({ mutationFn: async ({ id, v }: { id: number; v: { path_prefix: string; backend_id: number; priority: number; active: boolean } }) => apiClient.put(`/routing-rules/${id}`, { ...v, domain_id: domainID }), onSuccess: () => { message.success(t('common.save')) setEditing(null); rForm.resetFields(); invalidate() }, onError: (e: Error) => message.error(e.message), }) const del = useMutation({ mutationFn: async (id: number) => apiClient.delete(`/routing-rules/${id}`), onSuccess: invalidate, onError: (e: Error) => message.error(e.message), }) const quickToggle = useMutation({ mutationFn: async ({ id, row, checked }: { id: number; row: RoutingRule; checked: boolean }) => apiClient.put(`/routing-rules/${id}`, { ...row, active: checked }), onSuccess: invalidate, onError: (e: Error) => message.error(e.message), }) const backendLabel = (id: number) => { const b = backends?.find(x => x.id === id) return b ? `${b.name} (${b.address}:${b.port})` : `#${id}` } const columns: ColumnsType = [ { title: t('routing.pathPrefix'), dataIndex: 'path_prefix', key: 'path' }, { title: t('routing.backend'), dataIndex: 'backend_id', key: 'backend', render: (id: number) => backendLabel(id) }, { title: t('routing.priority'), dataIndex: 'priority', key: 'priority', width: 90 }, { title: t('routing.active'), dataIndex: 'active', key: 'active', width: 80, render: (v: boolean, r: RoutingRule) => ( quickToggle.mutate({ id: r.id, row: r, checked })} /> ), }, { title: t('common.actions'), key: 'a', width: 90, render: (_, r) => ( } > {t('domains.routingRulesHint')} { setOpen(false); setEditing(null); rForm.resetFields() }} onOk={() => { void rForm.submit() }} confirmLoading={create.isPending || update.isPending} destroyOnHidden >
editing ? update.mutate({ id: editing.id, v }) : create.mutate(v)} >
{ setOpen(false); setEditing(null); hForm.resetFields() }} onOk={() => { void hForm.submit() }} confirmLoading={create.isPending || update.isPending} destroyOnHidden > editing ? update.mutate({ id: editing.id, v }) : create.mutate(v)} > ) }