From 7c978d28ac8554b72a59913c41358533264e68e5 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Mon, 26 Jan 2026 11:16:32 -0800 Subject: [PATCH] Add per-interface packet and byte rate statistics Co-Authored-By: Claude Opus 4.5 --- snmp.go | 92 +++++++++++++++++++++++++++++++++++++++++++++++ static/index.html | 79 ++++++++++++++++++++++++++++++++-------- types.go | 16 ++++++--- 3 files changed, 168 insertions(+), 19 deletions(-) diff --git a/snmp.go b/snmp.go index 6a98ade..98af3d0 100644 --- a/snmp.go +++ b/snmp.go @@ -6,11 +6,29 @@ import ( "net" "regexp" "strings" + "sync" "time" "github.com/gosnmp/gosnmp" ) +type ifaceCounters struct { + inPkts uint64 + outPkts uint64 + inBytes uint64 + outBytes uint64 + timestamp time.Time +} + +type counterTracker struct { + mu sync.Mutex + counters map[string]*ifaceCounters +} + +var ifaceTracker = &counterTracker{ + counters: map[string]*ifaceCounters{}, +} + var portNameRewrites = []struct { re *regexp.Regexp repl string @@ -183,7 +201,13 @@ func (t *Tendrils) queryInterfaceStats(snmp *gosnmp.GoSNMP, node *Node, ifNames ifInErrors := t.getInterfaceTable(snmp, "1.3.6.1.2.1.2.2.1.14") ifOutErrors := t.getInterfaceTable(snmp, "1.3.6.1.2.1.2.2.1.20") + ifHCInOctets := t.getInterfaceTable64(snmp, "1.3.6.1.2.1.31.1.1.1.6") + ifHCOutOctets := t.getInterfaceTable64(snmp, "1.3.6.1.2.1.31.1.1.1.10") + ifHCInUcastPkts := t.getInterfaceTable64(snmp, "1.3.6.1.2.1.31.1.1.1.7") + ifHCOutUcastPkts := t.getInterfaceTable64(snmp, "1.3.6.1.2.1.31.1.1.1.11") + poeStats := t.getPoEStats(snmp, ifNames) + now := time.Now() t.nodes.mu.Lock() defer t.nodes.mu.Unlock() @@ -216,6 +240,46 @@ func (t *Tendrils) queryInterfaceStats(snmp *gosnmp.GoSNMP, node *Node, ifNames stats.OutErrors = uint64(outErr) } + inPkts, hasInPkts := ifHCInUcastPkts[ifIndex] + outPkts, hasOutPkts := ifHCOutUcastPkts[ifIndex] + inBytes, hasInBytes := ifHCInOctets[ifIndex] + outBytes, hasOutBytes := ifHCOutOctets[ifIndex] + + if hasInPkts && hasOutPkts && hasInBytes && hasOutBytes { + key := node.TypeID + ":" + name + ifaceTracker.mu.Lock() + prev, hasPrev := ifaceTracker.counters[key] + if hasPrev { + elapsed := now.Sub(prev.timestamp).Seconds() + if elapsed > 0 { + stats.InPktsRate = float64(inPkts-prev.inPkts) / elapsed + stats.OutPktsRate = float64(outPkts-prev.outPkts) / elapsed + stats.InBytesRate = float64(inBytes-prev.inBytes) / elapsed + stats.OutBytesRate = float64(outBytes-prev.outBytes) / elapsed + if stats.InPktsRate < 0 { + stats.InPktsRate = 0 + } + if stats.OutPktsRate < 0 { + stats.OutPktsRate = 0 + } + if stats.InBytesRate < 0 { + stats.InBytesRate = 0 + } + if stats.OutBytesRate < 0 { + stats.OutBytesRate = 0 + } + } + } + ifaceTracker.counters[key] = &ifaceCounters{ + inPkts: inPkts, + outPkts: outPkts, + inBytes: inBytes, + outBytes: outBytes, + timestamp: now, + } + ifaceTracker.mu.Unlock() + } + if poe, ok := poeStats[name]; ok { stats.PoE = poe } @@ -277,6 +341,34 @@ func (t *Tendrils) getInterfaceTable(snmp *gosnmp.GoSNMP, oid string) map[int]in return table } +func (t *Tendrils) getInterfaceTable64(snmp *gosnmp.GoSNMP, oid string) map[int]uint64 { + results, err := snmp.BulkWalkAll(oid) + if err != nil { + return nil + } + + table := map[int]uint64{} + for _, result := range results { + oidSuffix := strings.TrimPrefix(strings.TrimPrefix(result.Name, "."+oid), ".") + var ifIndex int + if _, err := fmt.Sscanf(oidSuffix, "%d", &ifIndex); err != nil { + continue + } + + switch v := result.Value.(type) { + case uint64: + table[ifIndex] = v + case uint: + table[ifIndex] = uint64(v) + case int: + table[ifIndex] = uint64(v) + case int64: + table[ifIndex] = uint64(v) + } + } + return table +} + func (t *Tendrils) getPoEStats(snmp *gosnmp.GoSNMP, ifNames map[int]string) map[string]*PoEStats { statusOID := "1.3.6.1.2.1.105.1.1.1.6" diff --git a/static/index.html b/static/index.html index f37c569..0722ab8 100644 --- a/static/index.html +++ b/static/index.html @@ -144,16 +144,20 @@ display: none; font-size: 8px; opacity: 0.8; + white-space: pre; + text-align: left; } .node .switch-port:hover .error-info, .node .uplink:hover .error-info { display: block; + will-change: transform; } .node .switch-port:hover, .node .uplink:hover { z-index: 100; + will-change: transform; } .node .uplink { @@ -407,7 +411,8 @@ } .node:has(.switch-port:hover) .node-info, - .node:has(.uplink:hover) .node-info { + .node:has(.uplink:hover) .node-info, + .node:has(.dante-info:hover) .node-info { display: none; } @@ -820,6 +825,31 @@ return { in: inErr, out: outErr }; } + function getInterfaceRates(node) { + if (!node.interfaces || node.interfaces.length === 0) return null; + const iface = node.interfaces[0]; + if (!iface.stats) return null; + return { + inPkts: iface.stats.in_pkts_rate || 0, + outPkts: iface.stats.out_pkts_rate || 0, + inBytes: iface.stats.in_bytes_rate || 0, + outBytes: iface.stats.out_bytes_rate || 0 + }; + } + + function formatRate(bps) { + if (bps < 1024) return bps.toFixed(0) + ' B/s'; + if (bps < 1024 * 1024) return (bps / 1024).toFixed(1) + ' KB/s'; + if (bps < 1024 * 1024 * 1024) return (bps / (1024 * 1024)).toFixed(1) + ' MB/s'; + return (bps / (1024 * 1024 * 1024)).toFixed(1) + ' GB/s'; + } + + function formatPps(pps) { + if (pps < 1000) return pps.toFixed(0) + ' pps'; + if (pps < 1000000) return (pps / 1000).toFixed(1) + 'K pps'; + return (pps / 1000000).toFixed(1) + 'M pps'; + } + let anonCounter = 0; function buildLocationTree(locations, parent) { @@ -917,10 +947,16 @@ if (speedClass) portEl.classList.add(speedClass); const errIn = switchConnection.errors?.in || 0; const errOut = switchConnection.errors?.out || 0; - const errInfo = document.createElement('div'); - errInfo.className = 'error-info'; - errInfo.textContent = 'err: ' + errIn + '/' + errOut; - portEl.appendChild(errInfo); + const statsInfo = document.createElement('div'); + statsInfo.className = 'error-info'; + let statsText = 'err: ' + errIn + '/' + errOut; + if (switchConnection.rates) { + const r = switchConnection.rates; + statsText += '\nrx: ' + formatRate(r.outBytes) + ' (' + formatPps(r.outPkts) + ')'; + statsText += '\ntx: ' + formatRate(r.inBytes) + ' (' + formatPps(r.inPkts) + ')'; + } + statsInfo.textContent = statsText; + portEl.appendChild(statsInfo); div.appendChild(portEl); } @@ -993,10 +1029,16 @@ if (speedClass) uplinkEl.classList.add(speedClass); const errIn = uplinkInfo.errors?.in || 0; const errOut = uplinkInfo.errors?.out || 0; - const errInfo = document.createElement('div'); - errInfo.className = 'error-info'; - errInfo.textContent = 'err: ' + errIn + '/' + errOut; - uplinkEl.appendChild(errInfo); + const statsInfo = document.createElement('div'); + statsInfo.className = 'error-info'; + let statsText = 'err: ' + errIn + '/' + errOut; + if (uplinkInfo.rates) { + const r = uplinkInfo.rates; + statsText += '\nrx: ' + formatRate(r.inBytes) + ' (' + formatPps(r.inPkts) + ')'; + statsText += '\ntx: ' + formatRate(r.outBytes) + ' (' + formatPps(r.outPkts) + ')'; + } + statsInfo.textContent = statsText; + uplinkEl.appendChild(statsInfo); div.appendChild(uplinkEl); } @@ -1258,7 +1300,9 @@ speedA: getInterfaceSpeed(link.node_a), speedB: getInterfaceSpeed(link.node_b), errorsA: getInterfaceErrors(link.node_a), - errorsB: getInterfaceErrors(link.node_b) + errorsB: getInterfaceErrors(link.node_b), + ratesA: getInterfaceRates(link.node_a), + ratesB: getInterfaceRates(link.node_b) }); } else if (aIsSwitch && !bIsSwitch) { const nodeLoc = nodeLocations.get(nodeB.typeid); @@ -1268,7 +1312,8 @@ switchName: getLabel(nodeA), external: !effectiveSwitch || effectiveSwitch.typeid !== nodeA.typeid, speed: getInterfaceSpeed(link.node_a), - errors: getInterfaceErrors(link.node_a) + errors: getInterfaceErrors(link.node_a), + rates: getInterfaceRates(link.node_a) }); } else if (bIsSwitch && !aIsSwitch) { const nodeLoc = nodeLocations.get(nodeA.typeid); @@ -1278,7 +1323,8 @@ switchName: getLabel(nodeB), external: !effectiveSwitch || effectiveSwitch.typeid !== nodeB.typeid, speed: getInterfaceSpeed(link.node_b), - errors: getInterfaceErrors(link.node_b) + errors: getInterfaceErrors(link.node_b), + rates: getInterfaceRates(link.node_b) }); } }); @@ -1334,14 +1380,16 @@ localPort: link.portA, remotePort: link.portB, localSpeed: link.speedA, - localErrors: link.errorsA + localErrors: link.errorsA, + localRates: link.ratesA }); adjacency.get(link.switchB.typeid).push({ neighbor: link.switchA, localPort: link.portB, remotePort: link.portA, localSpeed: link.speedB, - localErrors: link.errorsB + localErrors: link.errorsB, + localRates: link.ratesB }); }); @@ -1397,7 +1445,8 @@ remotePort: edge.localPort, parentName: getLabel(current), speed: reverseEdge?.localSpeed || 0, - errors: reverseEdge?.localErrors || null + errors: reverseEdge?.localErrors || null, + rates: reverseEdge?.localRates || null }); queue.push(edge.neighbor); } diff --git a/types.go b/types.go index f7be356..2a1c6a2 100644 --- a/types.go +++ b/types.go @@ -111,10 +111,14 @@ type Interface struct { } type InterfaceStats struct { - Speed uint64 `json:"speed,omitempty"` - InErrors uint64 `json:"in_errors,omitempty"` - OutErrors uint64 `json:"out_errors,omitempty"` - PoE *PoEStats `json:"poe,omitempty"` + Speed uint64 `json:"speed,omitempty"` + InErrors uint64 `json:"in_errors,omitempty"` + OutErrors uint64 `json:"out_errors,omitempty"` + InPktsRate float64 `json:"in_pkts_rate,omitempty"` + OutPktsRate float64 `json:"out_pkts_rate,omitempty"` + InBytesRate float64 `json:"in_bytes_rate,omitempty"` + OutBytesRate float64 `json:"out_bytes_rate,omitempty"` + PoE *PoEStats `json:"poe,omitempty"` } type PoEStats struct { @@ -195,6 +199,10 @@ func (s *InterfaceStats) String() string { parts = append(parts, fmt.Sprintf("err:%d/%d", s.InErrors, s.OutErrors)) } + if s.InBytesRate > 0 || s.OutBytesRate > 0 { + parts = append(parts, fmt.Sprintf("%.0f/%.0fB/s", s.InBytesRate, s.OutBytesRate)) + } + if s.PoE != nil { if s.PoE.MaxPower > 0 { parts = append(parts, fmt.Sprintf("poe:%.1f/%.1fW", s.PoE.Power, s.PoE.MaxPower))