diff --git a/VERSION b/VERSION index 633becb..1027df1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.44 +1.1.45 diff --git a/internal/handlers/system.go b/internal/handlers/system.go index 73712b3..a3606f7 100644 --- a/internal/handlers/system.go +++ b/internal/handlers/system.go @@ -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 { diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 93da9a0..41a9628 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -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.", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 65af0a2..cae1a0e 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -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.", diff --git a/management-ui/src/pages/Networks/Interfaces.tsx b/management-ui/src/pages/Networks/Interfaces.tsx index 19d0b64..33a90d8 100644 --- a/management-ui/src/pages/Networks/Interfaces.tsx +++ b/management-ui/src/pages/Networks/Interfaces.tsx @@ -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 { @@ -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(null) @@ -158,18 +168,48 @@ export default function InterfacesTab() { return (
- - {(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 ( - - {i.ifname}{v4[0] ? ` · ${v4[0]}` : ''} - - ) - })} - {(sys ?? []).length === 0 && } - + i.ifname !== 'lo')} + rowKey="ifname" + pagination={false} + columns={[ + { + title: t('networks.name'), dataIndex: 'ifname', key: 'ifname', width: 120, + render: (s: string) => {s}, + }, + { + title: t('networks.addresses'), key: 'addrs', + render: (_, row: SystemInterface) => { + const addrs = (row.addr_info ?? []).map(a => `${a.local}/${a.prefixlen}`) + return addrs.length + ? {addrs.map(a => {a})} + : + }, + }, + { + title: '▼ RX', key: 'rx', width: 130, + render: (_, row: SystemInterface) => ( + + {fmtBytes(row.rx_bytes ?? 0)} + + ), + }, + { + title: '▲ TX', key: 'tx', width: 130, + render: (_, row: SystemInterface) => ( + + {fmtBytes(row.tx_bytes ?? 0)} + + ), + }, + { + title: t('networks.linkType'), dataIndex: 'link_type', key: 'link_type', width: 90, + render: (v?: string) => v ? {v} : '—', + }, + ]} + locale={{ emptyText: t('networks.systemEmpty') }} + />