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

@@ -1 +1 @@
1.1.44
1.1.45

View File

@@ -765,13 +765,58 @@ type addrInfo struct {
}
type interfaceInfo struct {
IfIndex int `json:"ifindex"`
IfName string `json:"ifname"`
Flags []string `json:"flags"`
MTU int `json:"mtu"`
LinkType string `json:"link_type,omitempty"`
Address string `json:"address,omitempty"`
AddrInfo []addrInfo `json:"addr_info"`
IfIndex int `json:"ifindex"`
IfName string `json:"ifname"`
Flags []string `json:"flags"`
MTU int `json:"mtu"`
LinkType string `json:"link_type,omitempty"`
Address string `json:"address,omitempty"`
AddrInfo []addrInfo `json:"addr_info"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
RxPackets int64 `json:"rx_packets"`
TxPackets int64 `json:"tx_packets"`
RxDrop int64 `json:"rx_drop"`
TxDrop int64 `json:"tx_drop"`
}
type netDevStat struct {
RxBytes, TxBytes int64
RxPackets, TxPackets int64
RxDrop, TxDrop int64
}
// readNetDevStats parses /proc/net/dev and returns per-interface counters.
// Missing or unreadable: returns empty map (caller gets zero-value stats).
func readNetDevStats() map[string]netDevStat {
out := map[string]netDevStat{}
data, err := os.ReadFile("/proc/net/dev")
if err != nil {
return out
}
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
idx := strings.Index(line, ":")
if idx < 0 {
continue
}
name := strings.TrimSpace(line[:idx])
fields := strings.Fields(line[idx+1:])
if len(fields) < 12 {
continue
}
p := func(s string) int64 { n, _ := strconv.ParseInt(s, 10, 64); return n }
out[name] = netDevStat{
RxBytes: p(fields[0]),
RxPackets: p(fields[1]),
RxDrop: p(fields[3]),
TxBytes: p(fields[8]),
TxPackets: p(fields[9]),
TxDrop: p(fields[11]),
}
}
return out
}
// Interfaces enumerates the kernel-side network interfaces using
@@ -788,16 +833,24 @@ func (h *SystemHandler) Interfaces(c *gin.Context) {
response.OK(c, gin.H{"interfaces": []interfaceInfo{}})
return
}
devStats := readNetDevStats()
out := make([]interfaceInfo, 0, len(ifaces))
for _, ifc := range ifaces {
st := devStats[ifc.Name]
info := interfaceInfo{
IfIndex: ifc.Index,
IfName: ifc.Name,
MTU: ifc.MTU,
Address: ifc.HardwareAddr.String(),
LinkType: classifyLinkType(ifc),
Flags: flagsToList(ifc.Flags),
AddrInfo: []addrInfo{},
IfIndex: ifc.Index,
IfName: ifc.Name,
MTU: ifc.MTU,
Address: ifc.HardwareAddr.String(),
LinkType: classifyLinkType(ifc),
Flags: flagsToList(ifc.Flags),
AddrInfo: []addrInfo{},
RxBytes: st.RxBytes,
TxBytes: st.TxBytes,
RxPackets: st.RxPackets,
TxPackets: st.TxPackets,
RxDrop: st.RxDrop,
TxDrop: st.TxDrop,
}
addrs, err := ifc.Addrs()
if err != nil {

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