add interface stats and poe power monitoring via snmp

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2026-01-22 22:47:30 -08:00
parent 54fc941728
commit 5333d3e11e
2 changed files with 245 additions and 3 deletions

View File

@@ -14,6 +14,19 @@ type Interface struct {
Name string
MAC net.HardwareAddr
IPs map[string]net.IP
Stats *InterfaceStats
}
type InterfaceStats struct {
Speed uint64 // bits per second
InErrors uint64
OutErrors uint64
PoE *PoEStats
}
type PoEStats struct {
Power float64 // watts in use
MaxPower float64 // watts allocated/negotiated
}
func (i *Interface) String() string {
@@ -31,6 +44,9 @@ func (i *Interface) String() string {
if len(ips) > 0 {
parts = append(parts, fmt.Sprintf("%v", ips))
}
if i.Stats != nil {
parts = append(parts, i.Stats.String())
}
result := parts[0]
for _, p := range parts[1:] {
@@ -39,6 +55,45 @@ func (i *Interface) String() string {
return result
}
func (s *InterfaceStats) String() string {
var parts []string
if s.Speed > 0 {
if s.Speed >= 1000000000 {
parts = append(parts, fmt.Sprintf("%dG", s.Speed/1000000000))
} else if s.Speed >= 1000000 {
parts = append(parts, fmt.Sprintf("%dM", s.Speed/1000000))
} else {
parts = append(parts, fmt.Sprintf("%d", s.Speed))
}
}
if s.InErrors > 0 || s.OutErrors > 0 {
parts = append(parts, fmt.Sprintf("err:%d/%d", s.InErrors, s.OutErrors))
}
if s.PoE != nil {
if s.PoE.MaxPower > 0 {
parts = append(parts, fmt.Sprintf("poe:%.1f/%.1fW", s.PoE.Power, s.PoE.MaxPower))
} else {
parts = append(parts, fmt.Sprintf("poe:%.1fW", s.PoE.Power))
}
}
return "[" + fmt.Sprintf("%s", joinParts(parts)) + "]"
}
func joinParts(parts []string) string {
result := ""
for i, p := range parts {
if i > 0 {
result += " "
}
result += p
}
return result
}
type Node struct {
Name string
Interfaces map[string]*Interface

187
snmp.go
View File

@@ -124,6 +124,7 @@ func (t *Tendrils) querySNMPDevice(node *Node, ip net.IP) {
t.querySysName(snmp, node)
t.queryInterfaceMACs(snmp, node)
t.queryInterfaceStats(snmp, node)
t.queryBridgeMIB(snmp, node)
}
@@ -192,6 +193,192 @@ func (t *Tendrils) queryInterfaceMACs(snmp *gosnmp.GoSNMP, node *Node) {
}
}
func (t *Tendrils) queryInterfaceStats(snmp *gosnmp.GoSNMP, node *Node) {
ifNames := t.getInterfaceNames(snmp)
ifOperStatus := t.getInterfaceTable(snmp, "1.3.6.1.2.1.2.2.1.8")
ifHighSpeed := t.getInterfaceTable(snmp, "1.3.6.1.2.1.31.1.1.1.15")
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")
poeStats := t.getPoEStats(snmp, ifNames)
t.nodes.mu.Lock()
defer t.nodes.mu.Unlock()
for ifIndex, name := range ifNames {
name = rewritePortName(name)
iface, exists := node.Interfaces[name]
if !exists {
continue
}
status, hasStatus := ifOperStatus[ifIndex]
isUp := hasStatus && status == 1
if !isUp {
iface.Stats = nil
continue
}
stats := &InterfaceStats{}
if speed, ok := ifHighSpeed[ifIndex]; ok {
stats.Speed = uint64(speed) * 1000000
}
if inErr, ok := ifInErrors[ifIndex]; ok {
stats.InErrors = uint64(inErr)
}
if outErr, ok := ifOutErrors[ifIndex]; ok {
stats.OutErrors = uint64(outErr)
}
if poe, ok := poeStats[name]; ok {
stats.PoE = poe
}
iface.Stats = stats
}
}
func (t *Tendrils) getInterfaceTable(snmp *gosnmp.GoSNMP, oid string) map[int]int {
results, err := snmp.BulkWalkAll(oid)
if err != nil {
return nil
}
table := map[int]int{}
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
}
var value int
switch v := result.Value.(type) {
case int:
value = v
case uint:
value = int(v)
case int64:
value = int(v)
case uint64:
value = int(v)
default:
continue
}
table[ifIndex] = value
}
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"
statusResults, err := snmp.BulkWalkAll(statusOID)
if err != nil {
return nil
}
powerTable := t.getPoETable(snmp, "1.3.6.1.4.1.4526.10.15.1.1.1.2")
maxPowerTable := t.getPoETable(snmp, "1.3.6.1.4.1.4526.10.15.1.1.1.1")
stats := map[string]*PoEStats{}
for _, result := range statusResults {
oidSuffix := strings.TrimPrefix(strings.TrimPrefix(result.Name, "."+statusOID), ".")
parts := strings.Split(oidSuffix, ".")
if len(parts) < 2 {
continue
}
var portIndex int
if _, err := fmt.Sscanf(parts[1], "%d", &portIndex); err != nil {
continue
}
var status int
switch v := result.Value.(type) {
case int:
status = v
case uint:
status = int(v)
case int64:
status = int(v)
case uint64:
status = int(v)
default:
continue
}
ifName := rewritePortName(ifNames[portIndex])
if ifName == "" {
continue
}
if status != 3 {
continue
}
poe := &PoEStats{}
if power, ok := powerTable[portIndex]; ok {
poe.Power = float64(power) / 1000.0
}
if maxPower, ok := maxPowerTable[portIndex]; ok {
poe.MaxPower = float64(maxPower) / 1000.0
}
if t.DebugSNMP {
log.Printf("[snmp] %s: poe port %d (%s) power=%v maxpower=%v", snmp.Target, portIndex, ifName, powerTable[portIndex], maxPowerTable[portIndex])
}
if poe.Power > 0 || poe.MaxPower > 0 {
stats[ifName] = poe
}
}
return stats
}
func (t *Tendrils) getPoETable(snmp *gosnmp.GoSNMP, oid string) map[int]int {
results, err := snmp.BulkWalkAll(oid)
if err != nil {
return nil
}
table := map[int]int{}
for _, result := range results {
oidSuffix := strings.TrimPrefix(strings.TrimPrefix(result.Name, "."+oid), ".")
parts := strings.Split(oidSuffix, ".")
if len(parts) < 2 {
continue
}
var portIndex int
if _, err := fmt.Sscanf(parts[1], "%d", &portIndex); err != nil {
continue
}
var value int
switch v := result.Value.(type) {
case int:
value = v
case uint:
value = int(v)
case int64:
value = int(v)
case uint64:
value = int(v)
default:
continue
}
table[portIndex] = value
}
return table
}
func (t *Tendrils) queryBridgeMIB(snmp *gosnmp.GoSNMP, node *Node) {
portOID := "1.3.6.1.2.1.17.7.1.2.2.1.2"