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:
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Button, Card, Col, Form, Input, InputNumber, Modal,
|
Button, Card, Col, Form, Input, InputNumber, Modal,
|
||||||
Row, Select, Switch, Table, Tag, Typography, message,
|
Row, Select, Switch, Table, Tag, Tooltip, Typography, message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
import { ArrowLeftOutlined, DatabaseOutlined, PlusOutlined } from '@ant-design/icons'
|
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 ?? []
|
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() {
|
export default function BackendDetailPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -69,14 +89,40 @@ export default function BackendDetailPage() {
|
|||||||
enabled: !isNaN(backendID),
|
enabled: !isNaN(backendID),
|
||||||
})
|
})
|
||||||
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
|
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>()
|
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({
|
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: () => {
|
onSuccess: () => {
|
||||||
message.success(t('common.save'))
|
message.success(t('common.save'))
|
||||||
void qc.invalidateQueries({ queryKey: ['backends'] })
|
void qc.invalidateQueries({ queryKey: ['backends'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['backend', backendID] })
|
void qc.invalidateQueries({ queryKey: ['backend', backendID] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['domains'] })
|
||||||
},
|
},
|
||||||
onError: (e: Error) => message.error(e.message),
|
onError: (e: Error) => message.error(e.message),
|
||||||
})
|
})
|
||||||
@@ -164,7 +210,7 @@ export default function BackendDetailPage() {
|
|||||||
|
|
||||||
<Col xs={24} lg={14}>
|
<Col xs={24} lg={14}>
|
||||||
<Card size="small" title={t('backends.serversIn', { name: backend.name })}>
|
<Card size="small" title={t('backends.serversIn', { name: backend.name })}>
|
||||||
<ServerPanel backendID={backendID} />
|
<ServerPanel backendID={backendID} haproxyStats={haproxyStats ?? []} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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 { t } = useTranslation()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -234,6 +280,22 @@ function ServerPanel({ backendID }: { backendID: number }) {
|
|||||||
title: t('backends.active'), dataIndex: 'active', width: 80,
|
title: t('backends.active'), dataIndex: 'active', width: 80,
|
||||||
render: (v: boolean) => <StatusDot active={v} />,
|
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,
|
title: t('common.actions'), key: 'a', width: 100,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user