feat(ui): traffic tooltip (sess/bytes) auf Backend-Health-Chips

Backends- und Routing-Rules-Seite zeigen jetzt beim Hover auf den
UP/DOWN-Chip die aggregierten HAProxy-Stats des jeweiligen Backends:
aktive Sessions + kumulierte Bytes rein/raus seit letztem HAProxy-Start.
Daten kommen aus dem bereits vorhandenen /haproxy/stats-Endpunkt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-20 07:21:16 +02:00
parent 20f3a4fe26
commit e4b66cfcac
3 changed files with 54 additions and 11 deletions

View File

@@ -1 +1 @@
1.1.43 1.1.44

View File

@@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { import {
Alert, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Alert, Button, Card, Form, Input, InputNumber, Modal, Popconfirm,
Select, Space, Switch, Table, Tag, Typography, message, Select, Space, 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 { DatabaseOutlined, PlusOutlined } from '@ant-design/icons' import { DatabaseOutlined, PlusOutlined } from '@ant-design/icons'
@@ -86,7 +86,17 @@ 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 } interface HAProxyStat {
backend: string; server: 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<HAProxyStat[]> { async function listHAProxyStats(): Promise<HAProxyStat[]> {
try { try {
const r = await apiClient.get('/haproxy/stats') const r = await apiClient.get('/haproxy/stats')
@@ -118,6 +128,16 @@ export default function BackendsPage() {
return 'DOWN' return 'DOWN'
} }
const backendTraffic = (id: number) => {
const servers = (haproxyStats ?? []).filter(s => s.backend === `eg_backend_${id}`)
if (!servers.length) return null
return {
sessions: servers.reduce((a, s) => a + (s.sessions ?? 0), 0),
bytesIn: servers.reduce((a, s) => a + (s.bytes_in ?? 0), 0),
bytesOut: servers.reduce((a, s) => a + (s.bytes_out ?? 0), 0),
}
}
// server-counts pro Backend laden wir lazy bei Expansion; in der // server-counts pro Backend laden wir lazy bei Expansion; in der
// Tabelle reicht ein Hinweis ob 0 / N Server. // Tabelle reicht ein Hinweis ob 0 / N Server.
const { data: serverCounts } = useQuery({ const { data: serverCounts } = useQuery({
@@ -243,7 +263,15 @@ export default function BackendsPage() {
const s = backendLiveStatus(row.id) const s = backendLiveStatus(row.id)
if (!s) return <Text type="secondary" style={{ fontSize: 12 }}></Text> if (!s) return <Text type="secondary" style={{ fontSize: 12 }}></Text>
const color = s === 'UP' ? 'green' : s === 'DEGRADED' ? 'orange' : 'red' const color = s === 'UP' ? 'green' : s === 'DEGRADED' ? 'orange' : 'red'
return <Tag color={color} style={{ margin: 0 }}>{s}</Tag> const tr = backendTraffic(row.id)
const tip = tr
? `${tr.sessions} sess · ↓${fmtBytes(tr.bytesIn)}${fmtBytes(tr.bytesOut)}`
: undefined
return (
<Tooltip title={tip}>
<Tag color={color} style={{ margin: 0 }}>{s}</Tag>
</Tooltip>
)
}, },
}, },
{ title: t('backends.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> }, { title: t('backends.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },

View File

@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, message } from 'antd' import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, Tooltip, message } from 'antd'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import { BranchesOutlined, PlusOutlined } from '@ant-design/icons' import { BranchesOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@@ -50,7 +50,14 @@ async function listBackends(): Promise<Backend[]> {
return (r.data.data as { backends?: Backend[] }).backends ?? [] return (r.data.data as { backends?: Backend[] }).backends ?? []
} }
interface HAProxyStat { backend: string; status: string } 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<HAProxyStat[]> { async function listHAProxyStats(): Promise<HAProxyStat[]> {
try { try {
const r = await apiClient.get('/haproxy/stats') const r = await apiClient.get('/haproxy/stats')
@@ -77,11 +84,15 @@ export default function RoutingRulesPage() {
const b = backends?.find((x) => x.id === id) const b = backends?.find((x) => x.id === id)
return b ? `${b.name} (${b.address}:${b.port})` : `#${id}` return b ? `${b.name} (${b.address}:${b.port})` : `#${id}`
} }
const backendHealth = (id: number): 'UP' | 'DOWN' | null => { const backendHealth = (id: number) => {
if (!haproxyStats?.length) return null if (!haproxyStats?.length) return null
const servers = haproxyStats.filter(s => s.backend === `eg_backend_${id}`) const servers = haproxyStats.filter(s => s.backend === `eg_backend_${id}`)
if (!servers.length) return null if (!servers.length) return null
return servers.some(s => s.status === 'UP') ? 'UP' : 'DOWN' 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<RoutingRule | null>(null) const [editing, setEditing] = useState<RoutingRule | null>(null)
@@ -119,12 +130,16 @@ export default function RoutingRulesPage() {
{ {
title: t('routing.backend'), dataIndex: 'backend_id', key: 'backend', title: t('routing.backend'), dataIndex: 'backend_id', key: 'backend',
render: (id: number) => { render: (id: number) => {
const health = backendHealth(id) const h = backendHealth(id)
const tip = h ? `${h.sessions} sess · ↓${fmtBytes(h.bytesIn)}${fmtBytes(h.bytesOut)}` : undefined
return ( return (
<Space size={4}> <Space size={4}>
<span>{backendLabel(id)}</span> <span>{backendLabel(id)}</span>
{health === 'UP' && <Tag color="green" style={{ margin: 0 }}>UP</Tag>} {h && (
{health === 'DOWN' && <Tag color="red" style={{ margin: 0 }}>DOWN</Tag>} <Tooltip title={tip}>
<Tag color={h.up ? 'green' : 'red'} style={{ margin: 0 }}>{h.up ? 'UP' : 'DOWN'}</Tag>
</Tooltip>
)}
</Space> </Space>
) )
}, },