feat(networks): Live-Traffic-Zähler in System-Interfaces-Card

GET /system/interfaces liefert jetzt rx_bytes/tx_bytes/rx_packets/
tx_packets/rx_drop/tx_drop aus /proc/net/dev. Die System-Interfaces-
Card in Netzwerk → Interfaces zeigt die Werte als Mini-Tabelle mit
10s-Refetch; Hover auf den Bytes-Werten zeigt Paketzähler + Drops.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-20 14:09:37 +02:00
parent e4b66cfcac
commit bd6e67f059
5 changed files with 130 additions and 31 deletions

View File

@@ -138,7 +138,10 @@
"interfaces": "Interfaces",
"routes": "Routen"
},
"systemDiscovered": "System-Interfaces (read-only)",
"systemDiscovered": "System-Interfaces — Live-Traffic-Zähler (seit letztem HAProxy-Start)",
"addresses": "Adressen",
"linkType": "Typ",
"systemEmpty": "Keine Kernel-Interfaces gefunden",
"addInterface": "Interface hinzufügen",
"editInterface": "Interface bearbeiten",
"emptyTitle": "Noch keine verwalteten Interfaces.",

View File

@@ -138,7 +138,10 @@
"interfaces": "Interfaces",
"routes": "Routes"
},
"systemDiscovered": "System interfaces (read-only)",
"systemDiscovered": "System interfaces — live traffic counters (since last HAProxy start)",
"addresses": "Addresses",
"linkType": "Type",
"systemEmpty": "No kernel interfaces found",
"addInterface": "Add interface",
"editInterface": "Edit interface",
"emptyTitle": "No managed interfaces yet.",

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd'
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, Tooltip, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ClusterOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@@ -44,6 +44,16 @@ interface SystemInterface {
address?: string
flags?: string[]
addr_info?: Array<{ family: 'inet' | 'inet6'; local: string; prefixlen: number }>
rx_bytes: number; tx_bytes: number
rx_packets: number; tx_packets: number
rx_drop: number; tx_drop: 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 listInterfaces(): Promise<NetworkInterface[]> {
@@ -70,7 +80,7 @@ export default function InterfacesTab() {
const qc = useQueryClient()
const { data: ifs, isLoading } = useQuery({ queryKey: ['network-interfaces'], queryFn: listInterfaces })
const { data: sys } = useQuery({ queryKey: ['system', 'interfaces'], queryFn: listSystemInterfaces, refetchInterval: 60_000 })
const { data: sys } = useQuery({ queryKey: ['system', 'interfaces'], queryFn: listSystemInterfaces, refetchInterval: 10_000 })
const { data: zones } = useQuery({ queryKey: ['fw-zones'], queryFn: listZones })
const [editing, setEditing] = useState<NetworkInterface | null>(null)
@@ -158,18 +168,48 @@ export default function InterfacesTab() {
return (
<div>
<Card title={t('networks.systemDiscovered')} className="mb-12" size="small">
<Space wrap>
{(sys ?? []).map((i) => {
const v4 = (i.addr_info ?? []).filter((a) => a.family === 'inet').map((a) => `${a.local}/${a.prefixlen}`)
const v6 = (i.addr_info ?? []).filter((a) => a.family === 'inet6').map((a) => `${a.local}/${a.prefixlen}`)
return (
<Tooltip key={i.ifname} title={[...v4, ...v6].join(' · ') || '—'}>
<Tag>{i.ifname}{v4[0] ? ` · ${v4[0]}` : ''}</Tag>
</Tooltip>
)
})}
{(sys ?? []).length === 0 && <Typography.Text type="secondary"></Typography.Text>}
</Space>
<Table
size="small"
dataSource={(sys ?? []).filter(i => i.ifname !== 'lo')}
rowKey="ifname"
pagination={false}
columns={[
{
title: t('networks.name'), dataIndex: 'ifname', key: 'ifname', width: 120,
render: (s: string) => <code>{s}</code>,
},
{
title: t('networks.addresses'), key: 'addrs',
render: (_, row: SystemInterface) => {
const addrs = (row.addr_info ?? []).map(a => `${a.local}/${a.prefixlen}`)
return addrs.length
? <Space size={4} wrap>{addrs.map(a => <Tag key={a} style={{ fontFamily: 'monospace', fontSize: 11 }}>{a}</Tag>)}</Space>
: <Typography.Text type="secondary"></Typography.Text>
},
},
{
title: '▼ RX', key: 'rx', width: 130,
render: (_, row: SystemInterface) => (
<Tooltip title={`${row.rx_packets?.toLocaleString()} pkts${row.rx_drop ? ` · ${row.rx_drop} drop` : ''}`}>
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{fmtBytes(row.rx_bytes ?? 0)}</span>
</Tooltip>
),
},
{
title: '▲ TX', key: 'tx', width: 130,
render: (_, row: SystemInterface) => (
<Tooltip title={`${row.tx_packets?.toLocaleString()} pkts${row.tx_drop ? ` · ${row.tx_drop} drop` : ''}`}>
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{fmtBytes(row.tx_bytes ?? 0)}</span>
</Tooltip>
),
},
{
title: t('networks.linkType'), dataIndex: 'link_type', key: 'link_type', width: 90,
render: (v?: string) => v ? <Tag>{v}</Tag> : '—',
},
]}
locale={{ emptyText: t('networks.systemEmpty') }}
/>
</Card>
<DataTable