diff --git a/VERSION b/VERSION index b1468fd..d388d61 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.46 +1.1.47 diff --git a/management-ui/src/pages/Backends/Detail.tsx b/management-ui/src/pages/Backends/Detail.tsx index e2cf81e..21270c3 100644 --- a/management-ui/src/pages/Backends/Detail.tsx +++ b/management-ui/src/pages/Backends/Detail.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { Button, Card, Col, Form, Input, InputNumber, Modal, - Row, Select, Switch, Table, Tag, Typography, message, + 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' @@ -56,6 +56,26 @@ async function listDomains(): Promise { return (r.data.data as { domains?: DomainFull[] }).domains ?? [] } +interface HAProxyStat { + backend: string; server: string; status: string + sessions: number; bytes_in: number; bytes_out: 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' +} + export default function BackendDetailPage() { const { t } = useTranslation() const { id } = useParams<{ id: string }>() @@ -69,14 +89,40 @@ export default function BackendDetailPage() { 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) => { await apiClient.put(`/backends/${backendID}`, v) }, + 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), }) @@ -164,7 +210,7 @@ export default function BackendDetailPage() { - + @@ -172,7 +218,7 @@ export default function BackendDetailPage() { ) } -function ServerPanel({ backendID }: { backendID: number }) { +function ServerPanel({ backendID, haproxyStats }: { backendID: number; haproxyStats: HAProxyStat[] }) { const { t } = useTranslation() const qc = useQueryClient() const [open, setOpen] = useState(false) @@ -234,6 +280,22 @@ function ServerPanel({ backendID }: { backendID: number }) { title: t('backends.active'), dataIndex: 'active', width: 80, render: (v: boolean) => , }, + { + title: 'Live', key: 'live', width: 160, + render: (_, r) => { + const stat = haproxyStats.find( + s => s.backend === `eg_backend_${backendID}` && s.server === r.name + ) + if (!stat) return โ€” + const color = stat.status === 'UP' ? 'green' : stat.status === 'no check' ? 'default' : 'red' + return ( + + {stat.status} + {stat.sessions} sess + + ) + }, + }, { title: t('common.actions'), key: 'a', width: 100, render: (_, r) => (