feat(mac-locations): VLAN-aware port MAC counts, access/trunk thresholds, likely_attachment

Made-with: Cursor
This commit is contained in:
PTah
2026-04-13 13:11:20 +10:00
parent 2d8992549b
commit 42e3c0c925
6 changed files with 244 additions and 16 deletions
+83 -2
View File
@@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -60,6 +61,8 @@ type MacLocationRow struct {
MAC string `json:"mac"`
ClientIP string `json:"client_ip,omitempty"`
ClientName string `json:"client_name,omitempty"`
MacsOnPort int `json:"macs_on_port"`
PortKind string `json:"port_kind"` // access | ambiguous | strong_transit
CheckedAt time.Time `json:"checked_at"`
}
@@ -434,6 +437,35 @@ func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) {
}
limit = n
}
accessMax := 8
trunkMin := 32
if v := strings.TrimSpace(r.URL.Query().Get("access_max_macs")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 4096 {
writeError(w, http.StatusBadRequest, "access_max_macs must be between 1 and 4096")
return
}
accessMax = n
}
if v := strings.TrimSpace(r.URL.Query().Get("trunk_min_macs")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 2 || n > 65536 {
writeError(w, http.StatusBadRequest, "trunk_min_macs must be between 2 and 65536")
return
}
trunkMin = n
}
if accessMax >= trunkMin {
writeError(w, http.StatusBadRequest, "access_max_macs must be less than trunk_min_macs")
return
}
counts, err := h.store.CountDistinctMACsPerSwitchPort(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
snmpItems := h.store.ListSNMPResults(id, 200000)
ipToName := make(map[string]string, len(snmpItems))
for _, s := range snmpItems {
@@ -451,6 +483,11 @@ func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) {
items := h.store.ListPortDevicesByMac(id, norm, limit)
out := make([]MacLocationRow, 0, len(items))
for _, r := range items {
key := scans.PortFDBLocationKey(r.IP, r.IfIndex, r.BridgePort, r.Vlan)
nOn := counts[key]
if nOn < 1 {
nOn = 1
}
row := MacLocationRow{
SwitchIP: r.IP,
IfIndex: r.IfIndex,
@@ -458,6 +495,8 @@ func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) {
Vlan: r.Vlan,
MAC: r.MAC,
ClientIP: strings.TrimSpace(r.LearnedIP),
MacsOnPort: nOn,
PortKind: scans.ClassifyPortKind(nOn, accessMax, trunkMin),
CheckedAt: r.CheckedAt,
}
if nm, ok := ipToName[r.IP]; ok {
@@ -470,9 +509,51 @@ func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) {
}
out = append(out, row)
}
sort.Slice(out, func(i, j int) bool {
ri, rj := scans.PortKindRank(out[i].PortKind), scans.PortKindRank(out[j].PortKind)
if ri != rj {
return ri < rj
}
if out[i].MacsOnPort != out[j].MacsOnPort {
return out[i].MacsOnPort < out[j].MacsOnPort
}
if out[i].SwitchIP != out[j].SwitchIP {
return out[i].SwitchIP < out[j].SwitchIP
}
if out[i].IfIndex != out[j].IfIndex {
return out[i].IfIndex < out[j].IfIndex
}
if out[i].Vlan != out[j].Vlan {
return out[i].Vlan < out[j].Vlan
}
return out[i].ClientIP < out[j].ClientIP
})
likely := make([]MacLocationRow, 0)
for _, row := range out {
if row.PortKind == "access" {
likely = append(likely, row)
}
}
sort.Slice(likely, func(i, j int) bool {
if likely[i].MacsOnPort != likely[j].MacsOnPort {
return likely[i].MacsOnPort < likely[j].MacsOnPort
}
if likely[i].SwitchIP != likely[j].SwitchIP {
return likely[i].SwitchIP < likely[j].SwitchIP
}
if likely[i].IfIndex != likely[j].IfIndex {
return likely[i].IfIndex < likely[j].IfIndex
}
return likely[i].Vlan < likely[j].Vlan
})
writeJSON(w, http.StatusOK, map[string]any{
"mac": norm,
"items": out,
"mac": norm,
"access_max_macs": accessMax,
"trunk_min_macs": trunkMin,
"likely_attachment": likely,
"items": out,
})
}