Add per-interface packet and byte rate statistics
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
92
snmp.go
92
snmp.go
@@ -6,11 +6,29 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gosnmp/gosnmp"
|
"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 {
|
var portNameRewrites = []struct {
|
||||||
re *regexp.Regexp
|
re *regexp.Regexp
|
||||||
repl string
|
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")
|
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")
|
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)
|
poeStats := t.getPoEStats(snmp, ifNames)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
t.nodes.mu.Lock()
|
t.nodes.mu.Lock()
|
||||||
defer t.nodes.mu.Unlock()
|
defer t.nodes.mu.Unlock()
|
||||||
@@ -216,6 +240,46 @@ func (t *Tendrils) queryInterfaceStats(snmp *gosnmp.GoSNMP, node *Node, ifNames
|
|||||||
stats.OutErrors = uint64(outErr)
|
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 {
|
if poe, ok := poeStats[name]; ok {
|
||||||
stats.PoE = poe
|
stats.PoE = poe
|
||||||
}
|
}
|
||||||
@@ -277,6 +341,34 @@ func (t *Tendrils) getInterfaceTable(snmp *gosnmp.GoSNMP, oid string) map[int]in
|
|||||||
return table
|
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 {
|
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"
|
statusOID := "1.3.6.1.2.1.105.1.1.1.6"
|
||||||
|
|
||||||
|
|||||||
@@ -144,16 +144,20 @@
|
|||||||
display: none;
|
display: none;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
white-space: pre;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node .switch-port:hover .error-info,
|
.node .switch-port:hover .error-info,
|
||||||
.node .uplink:hover .error-info {
|
.node .uplink:hover .error-info {
|
||||||
display: block;
|
display: block;
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node .switch-port:hover,
|
.node .switch-port:hover,
|
||||||
.node .uplink:hover {
|
.node .uplink:hover {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node .uplink {
|
.node .uplink {
|
||||||
@@ -407,7 +411,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.node:has(.switch-port:hover) .node-info,
|
.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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,6 +825,31 @@
|
|||||||
return { in: inErr, out: outErr };
|
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;
|
let anonCounter = 0;
|
||||||
|
|
||||||
function buildLocationTree(locations, parent) {
|
function buildLocationTree(locations, parent) {
|
||||||
@@ -917,10 +947,16 @@
|
|||||||
if (speedClass) portEl.classList.add(speedClass);
|
if (speedClass) portEl.classList.add(speedClass);
|
||||||
const errIn = switchConnection.errors?.in || 0;
|
const errIn = switchConnection.errors?.in || 0;
|
||||||
const errOut = switchConnection.errors?.out || 0;
|
const errOut = switchConnection.errors?.out || 0;
|
||||||
const errInfo = document.createElement('div');
|
const statsInfo = document.createElement('div');
|
||||||
errInfo.className = 'error-info';
|
statsInfo.className = 'error-info';
|
||||||
errInfo.textContent = 'err: ' + errIn + '/' + errOut;
|
let statsText = 'err: ' + errIn + '/' + errOut;
|
||||||
portEl.appendChild(errInfo);
|
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);
|
div.appendChild(portEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -993,10 +1029,16 @@
|
|||||||
if (speedClass) uplinkEl.classList.add(speedClass);
|
if (speedClass) uplinkEl.classList.add(speedClass);
|
||||||
const errIn = uplinkInfo.errors?.in || 0;
|
const errIn = uplinkInfo.errors?.in || 0;
|
||||||
const errOut = uplinkInfo.errors?.out || 0;
|
const errOut = uplinkInfo.errors?.out || 0;
|
||||||
const errInfo = document.createElement('div');
|
const statsInfo = document.createElement('div');
|
||||||
errInfo.className = 'error-info';
|
statsInfo.className = 'error-info';
|
||||||
errInfo.textContent = 'err: ' + errIn + '/' + errOut;
|
let statsText = 'err: ' + errIn + '/' + errOut;
|
||||||
uplinkEl.appendChild(errInfo);
|
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);
|
div.appendChild(uplinkEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1258,7 +1300,9 @@
|
|||||||
speedA: getInterfaceSpeed(link.node_a),
|
speedA: getInterfaceSpeed(link.node_a),
|
||||||
speedB: getInterfaceSpeed(link.node_b),
|
speedB: getInterfaceSpeed(link.node_b),
|
||||||
errorsA: getInterfaceErrors(link.node_a),
|
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) {
|
} else if (aIsSwitch && !bIsSwitch) {
|
||||||
const nodeLoc = nodeLocations.get(nodeB.typeid);
|
const nodeLoc = nodeLocations.get(nodeB.typeid);
|
||||||
@@ -1268,7 +1312,8 @@
|
|||||||
switchName: getLabel(nodeA),
|
switchName: getLabel(nodeA),
|
||||||
external: !effectiveSwitch || effectiveSwitch.typeid !== nodeA.typeid,
|
external: !effectiveSwitch || effectiveSwitch.typeid !== nodeA.typeid,
|
||||||
speed: getInterfaceSpeed(link.node_a),
|
speed: getInterfaceSpeed(link.node_a),
|
||||||
errors: getInterfaceErrors(link.node_a)
|
errors: getInterfaceErrors(link.node_a),
|
||||||
|
rates: getInterfaceRates(link.node_a)
|
||||||
});
|
});
|
||||||
} else if (bIsSwitch && !aIsSwitch) {
|
} else if (bIsSwitch && !aIsSwitch) {
|
||||||
const nodeLoc = nodeLocations.get(nodeA.typeid);
|
const nodeLoc = nodeLocations.get(nodeA.typeid);
|
||||||
@@ -1278,7 +1323,8 @@
|
|||||||
switchName: getLabel(nodeB),
|
switchName: getLabel(nodeB),
|
||||||
external: !effectiveSwitch || effectiveSwitch.typeid !== nodeB.typeid,
|
external: !effectiveSwitch || effectiveSwitch.typeid !== nodeB.typeid,
|
||||||
speed: getInterfaceSpeed(link.node_b),
|
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,
|
localPort: link.portA,
|
||||||
remotePort: link.portB,
|
remotePort: link.portB,
|
||||||
localSpeed: link.speedA,
|
localSpeed: link.speedA,
|
||||||
localErrors: link.errorsA
|
localErrors: link.errorsA,
|
||||||
|
localRates: link.ratesA
|
||||||
});
|
});
|
||||||
adjacency.get(link.switchB.typeid).push({
|
adjacency.get(link.switchB.typeid).push({
|
||||||
neighbor: link.switchA,
|
neighbor: link.switchA,
|
||||||
localPort: link.portB,
|
localPort: link.portB,
|
||||||
remotePort: link.portA,
|
remotePort: link.portA,
|
||||||
localSpeed: link.speedB,
|
localSpeed: link.speedB,
|
||||||
localErrors: link.errorsB
|
localErrors: link.errorsB,
|
||||||
|
localRates: link.ratesB
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1397,7 +1445,8 @@
|
|||||||
remotePort: edge.localPort,
|
remotePort: edge.localPort,
|
||||||
parentName: getLabel(current),
|
parentName: getLabel(current),
|
||||||
speed: reverseEdge?.localSpeed || 0,
|
speed: reverseEdge?.localSpeed || 0,
|
||||||
errors: reverseEdge?.localErrors || null
|
errors: reverseEdge?.localErrors || null,
|
||||||
|
rates: reverseEdge?.localRates || null
|
||||||
});
|
});
|
||||||
queue.push(edge.neighbor);
|
queue.push(edge.neighbor);
|
||||||
}
|
}
|
||||||
|
|||||||
8
types.go
8
types.go
@@ -114,6 +114,10 @@ type InterfaceStats struct {
|
|||||||
Speed uint64 `json:"speed,omitempty"`
|
Speed uint64 `json:"speed,omitempty"`
|
||||||
InErrors uint64 `json:"in_errors,omitempty"`
|
InErrors uint64 `json:"in_errors,omitempty"`
|
||||||
OutErrors uint64 `json:"out_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"`
|
PoE *PoEStats `json:"poe,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +199,10 @@ func (s *InterfaceStats) String() string {
|
|||||||
parts = append(parts, fmt.Sprintf("err:%d/%d", s.InErrors, s.OutErrors))
|
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 != nil {
|
||||||
if s.PoE.MaxPower > 0 {
|
if s.PoE.MaxPower > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("poe:%.1f/%.1fW", s.PoE.Power, s.PoE.MaxPower))
|
parts = append(parts, fmt.Sprintf("poe:%.1f/%.1fW", s.PoE.Power, s.PoE.MaxPower))
|
||||||
|
|||||||
Reference in New Issue
Block a user