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:
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user