import { useState } from 'react' import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, Tooltip, message } from 'antd' import type { ColumnsType } from 'antd/es/table' import { BranchesOutlined, PlusOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import DataTable from '../../components/DataTable' import EmptyState from '../../components/EmptyState' import PageHeader from '../../components/PageHeader' import ActionButtons from '../../components/ActionButtons' import apiClient, { isEnvelope } from '../../api/client' interface RoutingRule { id: number domain_id: number path_prefix: string backend_id: number priority: number active: boolean created_at: string updated_at: string } interface RuleFormValues { domain_id: number path_prefix: string backend_id: number priority: number active: boolean } interface Domain { id: number; name: string } interface Backend { id: number; name: string; address: string; port: number } async function listRules(): Promise { const r = await apiClient.get('/routing-rules') if (!isEnvelope(r.data)) return [] return (r.data.data as { routing_rules?: RoutingRule[] }).routing_rules ?? [] } async function listDomains(): Promise { const r = await apiClient.get('/domains') if (!isEnvelope(r.data)) return [] return (r.data.data as { domains?: Domain[] }).domains ?? [] } async function listBackends(): Promise { const r = await apiClient.get('/backends') if (!isEnvelope(r.data)) return [] return (r.data.data as { backends?: Backend[] }).backends ?? [] } interface HAProxyStat { backend: string; status: string; sessions: number; bytes_in: number; bytes_out: number } 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' } 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 RoutingRulesPage() { const { t } = useTranslation() const qc = useQueryClient() const { data: rules, isLoading } = useQuery({ queryKey: ['routing-rules'], queryFn: listRules }) const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains }) const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends }) const { data: haproxyStats } = useQuery({ queryKey: ['haproxy', 'stats'], queryFn: listHAProxyStats, refetchInterval: 15_000, }) const domainName = (id: number) => domains?.find((d) => d.id === id)?.name ?? `#${id}` const backendLabel = (id: number) => { const b = backends?.find((x) => x.id === id) return b ? `${b.name} (${b.address}:${b.port})` : `#${id}` } const backendHealth = (id: number) => { if (!haproxyStats?.length) return null const servers = haproxyStats.filter(s => s.backend === `eg_backend_${id}`) if (!servers.length) return null const up = servers.some(s => s.status === 'UP') const sessions = servers.reduce((a, s) => a + (s.sessions ?? 0), 0) const bytesIn = servers.reduce((a, s) => a + (s.bytes_in ?? 0), 0) const bytesOut = servers.reduce((a, s) => a + (s.bytes_out ?? 0), 0) return { up, sessions, bytesIn, bytesOut } } const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) const [form] = Form.useForm() const create = useMutation({ mutationFn: async (v: RuleFormValues) => { await apiClient.post('/routing-rules', v) }, onSuccess: () => { message.success(t('common.save')) setCreating(false) form.resetFields() void qc.invalidateQueries({ queryKey: ['routing-rules'] }) }, }) const update = useMutation({ mutationFn: async ({ id, v }: { id: number; v: RuleFormValues }) => { await apiClient.put(`/routing-rules/${id}`, v) }, onSuccess: () => { message.success(t('common.save')) setEditing(null) form.resetFields() void qc.invalidateQueries({ queryKey: ['routing-rules'] }) }, }) const del = useMutation({ mutationFn: async (id: number) => { await apiClient.delete(`/routing-rules/${id}`) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['routing-rules'] }) }, }) const quickToggle = useMutation({ mutationFn: async ({ id, row, checked }: { id: number; row: RoutingRule; checked: boolean }) => { const { id: _id, created_at: _ca, updated_at: _ua, ...body } = row await apiClient.put(`/routing-rules/${id}`, { ...body, active: checked }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['routing-rules'] }) }, onError: (e: Error) => message.error(e.message), }) const columns: ColumnsType = [ { title: t('routing.domain'), dataIndex: 'domain_id', key: 'domain', render: (id: number) => domainName(id) }, { title: t('routing.pathPrefix'), dataIndex: 'path_prefix', key: 'path' }, { title: t('routing.backend'), dataIndex: 'backend_id', key: 'backend', render: (id: number) => { const h = backendHealth(id) const tip = h ? `${h.sessions} sess · ↓${fmtBytes(h.bytesIn)} ↑${fmtBytes(h.bytesOut)}` : undefined return ( {backendLabel(id)} {h && ( {h.up ? 'UP' : 'DOWN'} )} ) }, }, { title: t('routing.priority'), dataIndex: 'priority', key: 'priority' }, { title: t('routing.active'), dataIndex: 'active', key: 'active', width: 80, render: (v: boolean, row: RoutingRule) => ( quickToggle.mutate({ id: row.id, row, checked })} /> ), }, { title: t('common.actions'), key: 'actions', render: (_, row) => ( { setEditing(row) form.setFieldsValue({ domain_id: row.domain_id, path_prefix: row.path_prefix, backend_id: row.backend_id, priority: row.priority, active: row.active, }) }} onDelete={() => del.mutate(row.id)} deleteConfirm={t('routing.deleteConfirm')} /> ), }, ] const openCreate = () => { setCreating(true); form.resetFields() form.setFieldsValue({ priority: 100, path_prefix: '/', active: true }) } return (
} title={t('routing.title')} subtitle={t('routing.intro')} /> } onClick={openCreate}> {t('routing.addRule')} } emptyContent={ } title={t('routing.emptyTitle')} description={t('routing.emptyDesc')} action={ } /> } /> { setEditing(null); setCreating(false) }} onOk={() => { void form.submit() }} confirmLoading={create.isPending || update.isPending} >
{ if (editing) update.mutate({ id: editing.id, v }) else create.mutate(v) }} >