diff --git a/internal/api/handler.go b/internal/api/handler.go index d436e03..d3396e3 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -364,8 +364,30 @@ func (h *Handler) getScanPortDevices(w http.ResponseWriter, r *http.Request) { } limit = n } + snmpItems := h.store.ListSNMPResults(id, 200000) + ipToName := make(map[string]string, len(snmpItems)) + for _, s := range snmpItems { + if !s.Success { + continue + } + name := strings.TrimSpace(s.SysName) + if name == "" { + continue + } + if _, ok := ipToName[s.IP]; !ok { + ipToName[s.IP] = name + } + } + items := h.store.ListPortDeviceResults(id, ip, ifIndex, limit) + for i := range items { + if lip := strings.TrimSpace(items[i].LearnedIP); lip != "" { + if nm, ok := ipToName[lip]; ok { + items[i].Hostname = nm + } + } + } writeJSON(w, http.StatusOK, map[string]any{ - "items": h.store.ListPortDeviceResults(id, ip, ifIndex, limit), + "items": items, }) } diff --git a/internal/scans/postgres_store.go b/internal/scans/postgres_store.go index b9a1998..9314821 100644 --- a/internal/scans/postgres_store.go +++ b/internal/scans/postgres_store.go @@ -542,8 +542,8 @@ func (s *PostgresStore) PurgeAllScanData() error { func (s *PostgresStore) SavePortDeviceResult(scanID string, result PortDeviceResult) error { query := ` -insert into scan_port_devices (scan_id, ip, if_index, bridge_port, mac, learned_ip, checked_at) -values ($1, $2, $3, $4, $5, $6, $7)` +insert into scan_port_devices (scan_id, ip, if_index, bridge_port, vlan, mac, learned_ip, checked_at) +values ($1, $2, $3, $4, $5, $6, $7, $8)` var learnedIP any if v := strings.TrimSpace(result.LearnedIP); v != "" { learnedIP = v @@ -557,6 +557,7 @@ values ($1, $2, $3, $4, $5, $6, $7)` result.IP, result.IfIndex, result.BridgePort, + result.Vlan, result.MAC, learnedIP, result.CheckedAt, @@ -569,12 +570,12 @@ func (s *PostgresStore) ListPortDeviceResults(scanID string, filterIP string, fi limit = 20000 } query := ` -select ip::text, if_index, bridge_port, coalesce(mac, ''), coalesce(learned_ip::text, ''), checked_at +select ip::text, if_index, bridge_port, vlan, coalesce(mac, ''), coalesce(learned_ip::text, ''), checked_at from scan_port_devices where scan_id = $1 and ($2 = '' or ip = $2::inet) and ($3 = 0 or if_index = $3 or bridge_port = $3) -order by if_index asc, mac asc, learned_ip asc +order by if_index asc, vlan asc, mac asc, learned_ip asc limit $4` rows, err := s.db.QueryContext(context.Background(), query, scanID, filterIP, filterIfIndex, limit) if err != nil { @@ -585,7 +586,7 @@ limit $4` out := make([]PortDeviceResult, 0, 256) for rows.Next() { var r PortDeviceResult - if err = rows.Scan(&r.IP, &r.IfIndex, &r.BridgePort, &r.MAC, &r.LearnedIP, &r.CheckedAt); err != nil { + if err = rows.Scan(&r.IP, &r.IfIndex, &r.BridgePort, &r.Vlan, &r.MAC, &r.LearnedIP, &r.CheckedAt); err != nil { return nil } out = append(out, r) diff --git a/internal/scans/runner.go b/internal/scans/runner.go index 8f88870..3fd11a1 100644 --- a/internal/scans/runner.go +++ b/internal/scans/runner.go @@ -266,7 +266,8 @@ func probePortDevices(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []P seen := make(map[string]struct{}) out := make([]PortDeviceResult, 0, len(combinedFdb)) for macTail, bridgePortRaw := range combinedFdb { - mac := normalizeMACKey(dottedDecimalToMACTail(macTail)) + vlan, macSix := parseFdbKeyVlanAndMacTail(macTail) + mac := normalizeMACKey(dottedDecimalToMACTail(macSix)) if mac == "" { continue } @@ -285,7 +286,7 @@ func probePortDevices(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []P ipsSet := macToIPs[mac] if len(ipsSet) == 0 { - key := fmt.Sprintf("%d|%s|", ifIndex, mac) + key := fmt.Sprintf("%d|%d|%s|", vlan, ifIndex, mac) if _, ok := seen[key]; ok { continue } @@ -294,6 +295,7 @@ func probePortDevices(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []P IP: ip, IfIndex: ifIndex, BridgePort: bridgePort, + Vlan: vlan, MAC: mac, LearnedIP: "", CheckedAt: checkedAt, @@ -301,7 +303,7 @@ func probePortDevices(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []P continue } for learnedIP := range ipsSet { - key := fmt.Sprintf("%d|%s|%s", ifIndex, mac, learnedIP) + key := fmt.Sprintf("%d|%d|%s|%s", vlan, ifIndex, mac, learnedIP) if _, ok := seen[key]; ok { continue } @@ -310,6 +312,7 @@ func probePortDevices(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []P IP: ip, IfIndex: ifIndex, BridgePort: bridgePort, + Vlan: vlan, MAC: mac, LearnedIP: learnedIP, CheckedAt: checkedAt, @@ -321,6 +324,9 @@ func probePortDevices(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []P 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 + } if out[i].MAC != out[j].MAC { return out[i].MAC < out[j].MAC } @@ -333,6 +339,19 @@ func probePortDevices(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []P return out } +// parseFdbKeyVlanAndMacTail — суффикс OID FDB: dot1q — {vlan}.{6 октетов MAC}; dot1d — только 6 октетов (vlan=0). +func parseFdbKeyVlanAndMacTail(macTail string) (vlan int, macSixTail string) { + p := strings.Split(strings.TrimSpace(macTail), ".") + if len(p) >= 7 { + vlan, _ = strconv.Atoi(p[0]) + return vlan, strings.Join(p[1:7], ".") + } + if len(p) >= 6 { + return 0, strings.Join(p[len(p)-6:], ".") + } + return 0, "" +} + // snmpNumericString — значение SNMP как строка (int/float/[]byte) для Atoi. func snmpNumericString(v any) string { switch x := v.(type) { diff --git a/internal/scans/store.go b/internal/scans/store.go index b2ec1ce..de717d7 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -122,8 +122,10 @@ type PortDeviceResult struct { IP string `json:"ip"` IfIndex int `json:"if_index"` BridgePort int `json:"bridge_port,omitempty"` + Vlan int `json:"vlan"` MAC string `json:"mac"` LearnedIP string `json:"learned_ip"` + Hostname string `json:"hostname,omitempty"` // подставляется при ответе API по sysName скана, не хранится в БД CheckedAt time.Time `json:"checked_at"` } @@ -419,6 +421,9 @@ func (s *MemoryStore) ListPortDeviceResults(scanID string, filterIP string, filt if filtered[i].IfIndex != filtered[j].IfIndex { return filtered[i].IfIndex < filtered[j].IfIndex } + if filtered[i].Vlan != filtered[j].Vlan { + return filtered[i].Vlan < filtered[j].Vlan + } if filtered[i].MAC != filtered[j].MAC { return filtered[i].MAC < filtered[j].MAC } diff --git a/internal/store/migrate.go b/internal/store/migrate.go index c7e6f82..967aa21 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -17,6 +17,9 @@ var scanPortDevicesSQL string //go:embed sql/004_scan_port_devices_bridge_port.sql var scanPortDevicesBridgePortSQL string +//go:embed sql/005_scan_port_devices_vlan.sql +var scanPortDevicesVlanSQL string + func RunMigrations(db *sql.DB) error { if _, err := db.Exec(initSQL); err != nil { return err @@ -30,5 +33,8 @@ func RunMigrations(db *sql.DB) error { if _, err := db.Exec(scanPortDevicesBridgePortSQL); err != nil { return err } + if _, err := db.Exec(scanPortDevicesVlanSQL); err != nil { + return err + } return nil } diff --git a/internal/store/sql/003_scan_port_devices.sql b/internal/store/sql/003_scan_port_devices.sql index 1dd942f..8607c2d 100644 --- a/internal/store/sql/003_scan_port_devices.sql +++ b/internal/store/sql/003_scan_port_devices.sql @@ -4,6 +4,7 @@ create table if not exists scan_port_devices ( ip inet not null, if_index int not null, bridge_port int not null default 0, + vlan int not null default 0, mac text not null, learned_ip inet null, checked_at timestamptz not null diff --git a/internal/store/sql/005_scan_port_devices_vlan.sql b/internal/store/sql/005_scan_port_devices_vlan.sql new file mode 100644 index 0000000..c2e60a3 --- /dev/null +++ b/internal/store/sql/005_scan_port_devices_vlan.sql @@ -0,0 +1 @@ +alter table scan_port_devices add column if not exists vlan int not null default 0; diff --git a/internal/webui/index.html b/internal/webui/index.html index c7fbc04..3732675 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -124,18 +124,20 @@
-
Таблица 2. Устройства за портом (MAC/IP по BRIDGE-MIB + ARP)
+
Таблица 2. Устройства за портом (VLAN, MAC, IP, имя — как MAC table; IP/имя при наличии ARP и строки SNMP в этом scan)
+ + - +
ifIndexVLAN MAC IPИмя (sysName)
Выберите устройство и порт в Таблице 1.
Выберите устройство и порт в Таблице 1.
@@ -341,7 +343,7 @@ deviceLinksTableBody.innerHTML = 'Выберите устройство в таблице выше.'; deviceLldpTextTableBody.innerHTML = - 'Выберите устройство и порт в Таблице 1.'; + 'Выберите устройство и порт в Таблице 1.'; updateDeviceLinksSortIndicators(); } @@ -384,23 +386,26 @@ const ifi = Number(p.if_index) || 0; if (ifi === sortKey || br === sortKey) hits.push(p); } - const byMac = new Map(); + const byVlanMac = new Map(); for (const p of hits) { const mk = normalizeMacLocal(p.mac); if (!mk) continue; + const vlan = Number(p.vlan) || 0; + const k = `${vlan}|${mk}`; const ip = String(p.learned_ip || "").trim(); - const cur = byMac.get(mk); + const cur = byVlanMac.get(k); const macDisp = String(p.mac || "").trim() || mk; - if (!cur || (ip && !cur.ip)) byMac.set(mk, { mac: macDisp, ip }); + if (!cur || (ip && !cur.ip)) byVlanMac.set(k, { mac: macDisp, vlan, ip }); } - const rows = [...byMac.values()]; + const rows = [...byVlanMac.values()]; const n = rows.length; if (n === 0) return { macText: "—", ipText: "—" }; if (n === 1) { const r = rows[0]; - return { macText: r.mac, ipText: r.ip || "—" }; + const macShow = r.vlan ? `${r.mac} (VLAN ${r.vlan})` : r.mac; + return { macText: macShow, ipText: r.ip || "—" }; } - return { macText: `несколько MAC (${n})`, ipText: "—" }; + return { macText: `несколько MAC/VLAN (${n})`, ipText: "—" }; } function attachFdbSummaries(rows, portItems) { @@ -541,10 +546,13 @@ selectedFdbKey = null; if (!items || items.length === 0) { deviceLldpTextTableBody.innerHTML = - 'За этим портом MAC/IP не найдены (или устройство не отдаёт BRIDGE-MIB/ARP).'; + 'За этим портом MAC/IP не найдены (или устройство не отдаёт BRIDGE-MIB/ARP).'; return; } const sorted = [...items].sort((a, b) => { + const va = Number(a.vlan) || 0; + const vb = Number(b.vlan) || 0; + if (va !== vb) return va - vb; const ma = String(a.mac || "").localeCompare(String(b.mac || "")); if (ma !== 0) return ma; return String(a.learned_ip || "").localeCompare(String(b.learned_ip || "")); @@ -553,22 +561,24 @@ .map((row) => { const mk = normalizeMacLocal(row.mac); const ip = String(row.learned_ip || "").trim(); - const key = `${mk}|${ip}`; - return `${escapeHtml(row.if_index)}${escapeHtml(row.mac || "—")}${escapeHtml(row.learned_ip || "—")}`; + const vlan = Number(row.vlan) || 0; + const key = `${vlan}|${mk}|${ip}`; + const host = String(row.hostname || "").trim(); + return `${escapeHtml(String(row.if_index ?? ""))}${escapeHtml(String(vlan))}${escapeHtml(row.mac || "—")}${escapeHtml(row.learned_ip || "—")}${escapeHtml(host || "—")}`; }) .join(""); } function loadPortDevicesForSelectedPort() { if (!selectedDeviceIP || selectedIfIndex == null) return; - deviceLldpTextTableBody.innerHTML = 'Загрузка…'; + deviceLldpTextTableBody.innerHTML = 'Загрузка…'; try { const items = lastPortDevItems.filter( (p) => Number(p.if_index) === selectedIfIndex || Number(p.bridge_port) === selectedIfIndex ); renderFdbTable2Body(items); } catch (e) { - deviceLldpTextTableBody.innerHTML = `${escapeHtml( + deviceLldpTextTableBody.innerHTML = `${escapeHtml( e.message )}`; } @@ -585,7 +595,7 @@ renderDeviceTableBody(); deviceLinksTableBody.innerHTML = 'Загрузка…'; - deviceLldpTextTableBody.innerHTML = 'Загрузка…'; + deviceLldpTextTableBody.innerHTML = 'Загрузка…'; try { const [linksRes, ifRes, pdRes] = await Promise.all([ @@ -619,13 +629,13 @@ selectedIfIndex = null; selectedFdbKey = null; deviceLldpTextTableBody.innerHTML = - 'Выберите строку в Таблице 1, чтобы увидеть MAC/IP за портом.'; + 'Выберите строку в Таблице 1, чтобы увидеть VLAN/MAC/IP за портом.'; setStatus( `Связи для ${ip}: строк табл.1 ${deviceLinksRowsCache.length} (IF-MIB ${ifItems.length}, /links ${myLinks.length}, FDB строк ${lastPortDevItems.length}).` ); } catch (e) { deviceLinksTableBody.innerHTML = `${escapeHtml(e.message)}`; - deviceLldpTextTableBody.innerHTML = `${escapeHtml(e.message)}`; + deviceLldpTextTableBody.innerHTML = `${escapeHtml(e.message)}`; } }