fix(ui): Domain-Sync + HAProxy-Live-Stats in Backend-Detail

- Domain-Attachments werden beim Speichern korrekt sync't
  (syncDomainAttachments war in Detail.tsx vergessen worden)
- Server-Tabelle in der Detail-Seite zeigt jetzt eine 'Live'-Spalte
  mit HAProxy-Status (UP/DOWN), aktive Sessions und Bytes per Hover-
  Tooltip; Daten kommen aus dem gemeinsamen ['haproxy','stats']-Query

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-20 21:40:55 +02:00
parent 7fab17c231
commit 95583e9657
2 changed files with 67 additions and 5 deletions

View File

@@ -1 +1 @@
1.1.46
1.1.47

View File

@@ -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<DomainFull[]> {
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<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'
}
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<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) => { 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() {
<Col xs={24} lg={14}>
<Card size="small" title={t('backends.serversIn', { name: backend.name })}>
<ServerPanel backendID={backendID} />
<ServerPanel backendID={backendID} haproxyStats={haproxyStats ?? []} />
</Card>
</Col>
</Row>
@@ -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) => <StatusDot active={v} />,
},
{
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 <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.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</Text>
</Tooltip>
)
},
},
{
title: t('common.actions'), key: 'a', width: 100,
render: (_, r) => (