From 5b8290156594344758b3b11c09d962b64f6c8b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9B=D1=83=D1=86=D0=B5=D0=BD=D0=BA=D0=BE=20=D0=90=D0=BD?= =?UTF-8?q?=D0=B4=D1=80=D0=B5=D0=B9=20=D0=90=D0=BD=D0=B0=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=B5=D0=B2=D0=B8=D1=87?= Date: Fri, 10 Apr 2026 17:44:42 +1000 Subject: [PATCH] =?UTF-8?q?feat(snmp,ui):=20=D1=83=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=D0=B0=20=D0=B7=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=BE=D0=BC=20(MAC/IP)=20=D0=B2=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=86=D0=B5=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавил сбор FDB/ARP по SNMP (BRIDGE-MIB + ipNetToMedia), хранение и API /port-devices. В UI Таблица 2 теперь показывает MAC/IP за выбранным портом после клика по строке Таблицы 1. Made-with: Cursor --- internal/api/handler.go | 39 ++++++ internal/scans/postgres_store.go | 46 +++++++ internal/scans/runner.go | 134 ++++++++++++++++++- internal/scans/store.go | 60 +++++++++ internal/store/migrate.go | 6 + internal/store/sql/003_scan_port_devices.sql | 15 +++ internal/webui/index.html | 89 +++++++----- 7 files changed, 350 insertions(+), 39 deletions(-) create mode 100644 internal/store/sql/003_scan_port_devices.sql diff --git a/internal/api/handler.go b/internal/api/handler.go index b947299..d436e03 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -69,6 +69,7 @@ func (h *Handler) Routes() http.Handler { mux.HandleFunc("GET /api/scans/{id}/snmp", h.getScanSNMP) mux.HandleFunc("GET /api/scans/{id}/lldp", h.getScanLLDP) mux.HandleFunc("GET /api/scans/{id}/interfaces", h.getScanInterfaces) + mux.HandleFunc("GET /api/scans/{id}/port-devices", h.getScanPortDevices) mux.HandleFunc("GET /api/scans/{id}/links", h.getScanLinks) mux.HandleFunc("GET /api/scans/{id}/topology", h.getScanTopology) mux.HandleFunc("POST /api/admin/purge-scans", h.postAdminPurgeScans) @@ -330,6 +331,44 @@ func (h *Handler) getScanInterfaces(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) getScanPortDevices(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "scan id is required") + return + } + if _, ok := h.store.GetScan(id); !ok { + writeError(w, http.StatusNotFound, "scan not found") + return + } + ip := strings.TrimSpace(r.URL.Query().Get("ip")) + if ip == "" { + writeError(w, http.StatusBadRequest, "query parameter ip is required") + return + } + ifIndex := 0 + if raw := strings.TrimSpace(r.URL.Query().Get("if_index")); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + writeError(w, http.StatusBadRequest, "if_index must be >= 0") + return + } + ifIndex = n + } + limit := 20000 + if raw := r.URL.Query().Get("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 || n > 100000 { + writeError(w, http.StatusBadRequest, "limit must be between 1 and 100000") + return + } + limit = n + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": h.store.ListPortDeviceResults(id, ip, ifIndex, limit), + }) +} + func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { diff --git a/internal/scans/postgres_store.go b/internal/scans/postgres_store.go index b28378a..2ef2932 100644 --- a/internal/scans/postgres_store.go +++ b/internal/scans/postgres_store.go @@ -538,3 +538,49 @@ func (s *PostgresStore) PurgeAllScanData() error { _, err := s.db.ExecContext(context.Background(), `truncate table scan_jobs restart identity cascade`) return err } + +func (s *PostgresStore) SavePortDeviceResult(scanID string, result PortDeviceResult) error { + query := ` +insert into scan_port_devices (scan_id, ip, if_index, mac, learned_ip, checked_at) +values ($1, $2, $3, $4, $5, $6)` + _, err := s.db.ExecContext( + context.Background(), + query, + scanID, + result.IP, + result.IfIndex, + result.MAC, + result.LearnedIP, + result.CheckedAt, + ) + return err +} + +func (s *PostgresStore) ListPortDeviceResults(scanID string, filterIP string, filterIfIndex int, limit int) []PortDeviceResult { + if limit <= 0 { + limit = 20000 + } + query := ` +select ip::text, if_index, 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) +order by if_index asc, mac asc, learned_ip asc +limit $4` + rows, err := s.db.QueryContext(context.Background(), query, scanID, filterIP, filterIfIndex, limit) + if err != nil { + return nil + } + defer rows.Close() + + out := make([]PortDeviceResult, 0, 256) + for rows.Next() { + var r PortDeviceResult + if err = rows.Scan(&r.IP, &r.IfIndex, &r.MAC, &r.LearnedIP, &r.CheckedAt); err != nil { + return nil + } + out = append(out, r) + } + return out +} diff --git a/internal/scans/runner.go b/internal/scans/runner.go index 838cac6..33810c2 100644 --- a/internal/scans/runner.go +++ b/internal/scans/runner.go @@ -102,7 +102,7 @@ func (r *Runner) run(job ScanJob) { } } if isUp && r.cfg.SNMPEnabled { - snmpRes, lldpRes, ifRes := probeSNMPAndLLDP(ip, r.cfg.SNMPCommunity, checkedAt, job.Options.PingTimeoutMS) + snmpRes, lldpRes, ifRes, portDevRes := probeSNMPAndLLDP(ip, r.cfg.SNMPCommunity, checkedAt, job.Options.PingTimeoutMS) _ = r.store.SaveSNMPResult(job.ID, snmpRes) for _, lr := range lldpRes { if err := r.store.SaveLLDPResult(job.ID, lr); err != nil { @@ -114,6 +114,11 @@ func (r *Runner) run(job ScanJob) { log.Printf("scan %s: save interface %s ifIndex=%d: %v", job.ID, iface.IP, iface.IfIndex, err) } } + for _, pd := range portDevRes { + if err := r.store.SavePortDeviceResult(job.ID, pd); err != nil { + log.Printf("scan %s: save port-device %s ifIndex=%d mac=%s: %v", job.ID, pd.IP, pd.IfIndex, pd.MAC, err) + } + } } mu.Lock() @@ -153,7 +158,7 @@ func (r *Runner) run(job ScanJob) { }) } -func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) (SNMPResult, []LLDPResult, []InterfaceResult) { +func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) (SNMPResult, []LLDPResult, []InterfaceResult, []PortDeviceResult) { if timeoutMS <= 0 { timeoutMS = 700 } @@ -170,7 +175,7 @@ func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) Retries: 1, } if err := client.Connect(); err != nil { - return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil, nil + return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil, nil, nil } defer client.Conn.Close() @@ -181,7 +186,7 @@ func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) } pkt, err := client.Get(oids) if err != nil { - return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil, nil + return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil, nil, nil } out := SNMPResult{IP: ip, Success: true, CheckedAt: checkedAt} @@ -208,10 +213,129 @@ func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) lldpRes := probeLLDP(client, ip, checkedAt) var ifList []InterfaceResult + var portDevices []PortDeviceResult if out.Success { ifList = probeIfTable(client, ip, checkedAt) + portDevices = probePortDevices(client, ip, checkedAt) } - return out, lldpRes, ifList + return out, lldpRes, ifList, portDevices +} + +// probePortDevices собирает MAC-адреса за портами (BRIDGE-MIB) и пытается сопоставить им IP через ARP (ipNetToMedia). +func probePortDevices(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []PortDeviceResult { + // dot1dBasePortIfIndex: bridge-port -> ifIndex + basePortIfIndex := walkAsMap(client, ".1.3.6.1.2.1.17.1.4.1.2") + // dot1dTpFdbPort: mac(6 octets) -> bridge-port + fdbMacToBridgePort := walkAsMap(client, ".1.3.6.1.2.1.17.4.3.1.2") + // ipNetToMediaPhysAddress: ifIndex.ip -> mac + arpIfIPToMac := walkAsMap(client, ".1.3.6.1.2.1.4.22.1.2") + + if len(basePortIfIndex) == 0 || len(fdbMacToBridgePort) == 0 { + return nil + } + + macToIPs := make(map[string]map[string]struct{}) + for key, mac := range arpIfIPToMac { + mac = strings.ToLower(strings.TrimSpace(mac)) + if mac == "" || !strings.Contains(mac, ":") { + continue + } + parts := strings.Split(key, ".") + if len(parts) < 5 { + continue + } + ipStr := strings.Join(parts[1:], ".") + if net.ParseIP(ipStr) == nil { + continue + } + if _, ok := macToIPs[mac]; !ok { + macToIPs[mac] = make(map[string]struct{}) + } + macToIPs[mac][ipStr] = struct{}{} + } + + seen := make(map[string]struct{}) + out := make([]PortDeviceResult, 0, len(fdbMacToBridgePort)) + for macTail, bridgePortRaw := range fdbMacToBridgePort { + mac := dottedDecimalToMAC(macTail) + if mac == "" { + continue + } + bridgePort, err := strconv.Atoi(strings.TrimSpace(bridgePortRaw)) + if err != nil || bridgePort <= 0 { + continue + } + ifIndexRaw, ok := basePortIfIndex[strconv.Itoa(bridgePort)] + if !ok { + continue + } + ifIndex, err := strconv.Atoi(strings.TrimSpace(ifIndexRaw)) + if err != nil || ifIndex <= 0 { + continue + } + + ipsSet := macToIPs[strings.ToLower(mac)] + if len(ipsSet) == 0 { + key := fmt.Sprintf("%d|%s|", ifIndex, mac) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, PortDeviceResult{ + IP: ip, + IfIndex: ifIndex, + MAC: mac, + LearnedIP: "", + CheckedAt: checkedAt, + }) + continue + } + for learnedIP := range ipsSet { + key := fmt.Sprintf("%d|%s|%s", ifIndex, mac, learnedIP) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, PortDeviceResult{ + IP: ip, + IfIndex: ifIndex, + MAC: mac, + LearnedIP: learnedIP, + CheckedAt: checkedAt, + }) + } + } + + sort.Slice(out, func(i, j int) bool { + if out[i].IfIndex != out[j].IfIndex { + return out[i].IfIndex < out[j].IfIndex + } + if out[i].MAC != out[j].MAC { + return out[i].MAC < out[j].MAC + } + return out[i].LearnedIP < out[j].LearnedIP + }) + const maxRows = 20000 + if len(out) > maxRows { + out = out[:maxRows] + } + return out +} + +func dottedDecimalToMAC(s string) string { + p := strings.Split(strings.TrimSpace(s), ".") + if len(p) != 6 { + return "" + } + b := make([]byte, 6) + for i := 0; i < 6; i++ { + n, err := strconv.Atoi(p[i]) + if err != nil || n < 0 || n > 255 { + return "" + } + b[i] = byte(n) + } + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b[0], b[1], b[2], b[3], b[4], b[5]) } // probeIfTable собирает ifDescr, ifName (ifXTable), ifOperStatus по IF-MIB для списка портов в UI. diff --git a/internal/scans/store.go b/internal/scans/store.go index 5de3758..021b83a 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -69,6 +69,8 @@ type Store interface { ListLLDPResults(scanID string, limit int) []LLDPResult SaveInterfaceResult(scanID string, result InterfaceResult) error ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult + SavePortDeviceResult(scanID string, result PortDeviceResult) error + ListPortDeviceResults(scanID string, filterIP string, filterIfIndex int, limit int) []PortDeviceResult // PurgeAllScanData удаляет все сканы и связанные строки (хосты, порты, SNMP, LLDP, интерфейсы). PurgeAllScanData() error } @@ -115,6 +117,15 @@ type InterfaceResult struct { CheckedAt time.Time `json:"checked_at"` } +// PortDeviceResult — MAC/IP, обнаруженные за конкретным портом (ifIndex) через BRIDGE-MIB + ARP. +type PortDeviceResult struct { + IP string `json:"ip"` + IfIndex int `json:"if_index"` + MAC string `json:"mac"` + LearnedIP string `json:"learned_ip"` + CheckedAt time.Time `json:"checked_at"` +} + type MemoryStore struct { mu sync.RWMutex jobs map[string]ScanJob @@ -123,6 +134,7 @@ type MemoryStore struct { snmp map[string][]SNMPResult lldp map[string][]LLDPResult ifaces map[string][]InterfaceResult + fdb map[string][]PortDeviceResult } func NewMemoryStore() *MemoryStore { @@ -133,6 +145,7 @@ func NewMemoryStore() *MemoryStore { snmp: make(map[string][]SNMPResult), lldp: make(map[string][]LLDPResult), ifaces: make(map[string][]InterfaceResult), + fdb: make(map[string][]PortDeviceResult), } } @@ -341,6 +354,7 @@ func (s *MemoryStore) PurgeAllScanData() error { s.snmp = make(map[string][]SNMPResult) s.lldp = make(map[string][]LLDPResult) s.ifaces = make(map[string][]InterfaceResult) + s.fdb = make(map[string][]PortDeviceResult) return nil } @@ -373,6 +387,52 @@ func (s *MemoryStore) ListInterfaceResults(scanID string, filterIP string, limit return out } +func (s *MemoryStore) SavePortDeviceResult(scanID string, result PortDeviceResult) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.jobs[scanID]; !ok { + return errors.New("scan not found") + } + s.fdb[scanID] = append(s.fdb[scanID], result) + return nil +} + +func (s *MemoryStore) ListPortDeviceResults(scanID string, filterIP string, filterIfIndex int, limit int) []PortDeviceResult { + if limit <= 0 { + limit = 20000 + } + s.mu.RLock() + defer s.mu.RUnlock() + items := s.fdb[scanID] + filtered := make([]PortDeviceResult, 0, len(items)) + for _, r := range items { + if filterIP != "" && r.IP != filterIP { + continue + } + if filterIfIndex > 0 && r.IfIndex != filterIfIndex { + continue + } + filtered = append(filtered, r) + } + sort.Slice(filtered, func(i, j int) bool { + if filtered[i].IfIndex != filtered[j].IfIndex { + return filtered[i].IfIndex < filtered[j].IfIndex + } + if filtered[i].MAC != filtered[j].MAC { + return filtered[i].MAC < filtered[j].MAC + } + return filtered[i].LearnedIP < filtered[j].LearnedIP + }) + if len(filtered) <= limit { + out := make([]PortDeviceResult, len(filtered)) + copy(out, filtered) + return out + } + out := make([]PortDeviceResult, limit) + copy(out, filtered[:limit]) + return out +} + func applyDefaultOptions(opts *ScanOptions) { if opts.PingTimeoutMS <= 0 { opts.PingTimeoutMS = 700 diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 3e43225..ba8ad47 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -11,6 +11,9 @@ var initSQL string //go:embed sql/002_scan_interfaces.sql var scanInterfacesSQL string +//go:embed sql/003_scan_port_devices.sql +var scanPortDevicesSQL string + func RunMigrations(db *sql.DB) error { if _, err := db.Exec(initSQL); err != nil { return err @@ -18,5 +21,8 @@ func RunMigrations(db *sql.DB) error { if _, err := db.Exec(scanInterfacesSQL); err != nil { return err } + if _, err := db.Exec(scanPortDevicesSQL); 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 new file mode 100644 index 0000000..b163e30 --- /dev/null +++ b/internal/store/sql/003_scan_port_devices.sql @@ -0,0 +1,15 @@ +create table if not exists scan_port_devices ( + id bigserial primary key, + scan_id text not null references scan_jobs(id) on delete cascade, + ip inet not null, + if_index int not null, + mac text not null, + learned_ip inet null, + checked_at timestamptz not null +); + +create index if not exists idx_scan_port_devices_scan_ip_if + on scan_port_devices (scan_id, ip, if_index); + +create index if not exists idx_scan_port_devices_scan_ip_mac + on scan_port_devices (scan_id, ip, mac); diff --git a/internal/webui/index.html b/internal/webui/index.html index 49bda7a..bedd509 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -91,7 +91,7 @@
Связи выбранного устройства: -

Кликните по строке в таблице «Найденные устройства». Таблица 1 — все интерфейсы из IF-MIB (SNMP) и LLDP-соседи по ifIndex; сортировка по «Локальный порт». Таблица 2 — сырые строки LLDP.

+

Кликните по строке в таблице «Найденные устройства». Таблица 1 — все интерфейсы из IF-MIB (SNMP) и LLDP-соседи по ifIndex; сортировка по «Локальный порт». Клик по строке Таблицы 1 загружает Таблицу 2 (MAC/IP за выбранным портом).

Таблица 1. Связи (IF-MIB + LLDP по портам)
@@ -116,17 +116,18 @@
-
Таблица 2. LLDP текстом (по одной строке на запись MIB)
+
Таблица 2. Устройства за портом (MAC/IP по BRIDGE-MIB + ARP)
- - + + + - +
Текст LLDPifIndexMACIP
Выберите устройство выше.
Выберите устройство и порт в Таблице 1.
@@ -191,6 +192,7 @@ /** @type {{sortKey:number,localPort:string,snmpInfo:string,targetName:string,targetIP:string,targetPort:string,chassis:string,resolved:string}[]} */ let deviceLinksRowsCache = []; let deviceLinksSort = { key: "port", dir: 1 }; + let selectedIfIndex = null; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -320,12 +322,13 @@ function clearDeviceDetailPanel() { selectedDeviceIP = null; + selectedIfIndex = null; selectedDeviceLabel.textContent = "—"; deviceLinksRowsCache = []; deviceLinksTableBody.innerHTML = 'Выберите устройство в таблице выше.'; deviceLldpTextTableBody.innerHTML = - 'Выберите устройство в таблице выше.'; + 'Выберите устройство и порт в Таблице 1.'; updateDeviceLinksSortIndicators(); } @@ -464,19 +467,37 @@ deviceLinksTableBody.innerHTML = arr .map( (row) => - `${escapeHtml(row.localPort)}${escapeHtml(row.snmpInfo)}${escapeHtml(row.targetName)}${escapeHtml(row.targetIP)}${escapeHtml(row.targetPort)}${escapeHtml(row.chassis)}${escapeHtml(row.resolved)}` + `${escapeHtml(row.localPort)}${escapeHtml(row.snmpInfo)}${escapeHtml(row.targetName)}${escapeHtml(row.targetIP)}${escapeHtml(row.targetPort)}${escapeHtml(row.chassis)}${escapeHtml(row.resolved)}` ) .join(""); } - function formatLldpTextLine(item) { - const chunks = []; - chunks.push(`Источник ${item.ip}`); - if (item.local_port_num) chunks.push(`локальный порт: ${item.local_port_num}`); - if (item.remote_sys_name) chunks.push(`удалённый sysName: ${item.remote_sys_name}`); - if (item.remote_port_id) chunks.push(`удалённый порт ID: ${item.remote_port_id}`); - if (item.remote_chassis_id) chunks.push(`удалённый chassis ID: ${item.remote_chassis_id}`); - return chunks.join(". ") + "."; + async function loadPortDevicesForSelectedPort() { + const scanId = scanIdInput.value.trim(); + if (!scanId || !selectedDeviceIP || !selectedIfIndex) return; + deviceLldpTextTableBody.innerHTML = 'Загрузка…'; + try { + const res = await fetch( + `/api/scans/${encodeURIComponent(scanId)}/port-devices?ip=${encodeURIComponent(selectedDeviceIP)}&if_index=${encodeURIComponent(selectedIfIndex)}&limit=20000` + ); + if (!res.ok) throw new Error(await res.text()); + const items = ((await res.json()) || {}).items || []; + if (items.length === 0) { + deviceLldpTextTableBody.innerHTML = + 'За этим портом MAC/IP не найдены (или устройство не отдаёт BRIDGE-MIB/ARP).'; + return; + } + deviceLldpTextTableBody.innerHTML = items + .map( + (row) => + `${escapeHtml(row.if_index)}${escapeHtml(row.mac || "—")}${escapeHtml(row.learned_ip || "—")}` + ) + .join(""); + } catch (e) { + deviceLldpTextTableBody.innerHTML = `${escapeHtml( + e.message + )}`; + } } async function selectDeviceAndLoadLinks(ip) { @@ -490,26 +511,22 @@ renderDeviceTableBody(); deviceLinksTableBody.innerHTML = 'Загрузка…'; - deviceLldpTextTableBody.innerHTML = 'Загрузка…'; + deviceLldpTextTableBody.innerHTML = 'Загрузка…'; try { - const [linksRes, lldpRes, ifRes] = await Promise.all([ + const [linksRes, ifRes] = await Promise.all([ fetch(`/api/scans/${encodeURIComponent(scanId)}/links?limit=20000`), - fetch(`/api/scans/${encodeURIComponent(scanId)}/lldp?limit=20000`), fetch( `/api/scans/${encodeURIComponent(scanId)}/interfaces?ip=${encodeURIComponent(ip)}&limit=8192` ), ]); if (!linksRes.ok) throw new Error("links: " + (await linksRes.text())); - if (!lldpRes.ok) throw new Error("lldp: " + (await lldpRes.text())); const linkItems = (await linksRes.json()).items || []; - const lldpItems = (await lldpRes.json()).items || []; let ifItems = []; if (ifRes.ok) { ifItems = ((await ifRes.json()) || {}).items || []; } const myLinks = linkItems.filter((x) => x.source_ip === ip); - const myLldp = lldpItems.filter((x) => x.ip === ip); deviceLinksRowsCache = buildMergedPortRows(ifItems, myLinks); updateDeviceLinksSortIndicators(); @@ -520,23 +537,15 @@ renderDeviceLinksTableBody(); } - if (myLldp.length === 0) { - deviceLldpTextTableBody.innerHTML = - 'Нет сырых записей LLDP для этого IP.'; - } else { - deviceLldpTextTableBody.innerHTML = myLldp - .map( - (row, i) => - `${i + 1}${escapeHtml(formatLldpTextLine(row))}` - ) - .join(""); - } + selectedIfIndex = null; + deviceLldpTextTableBody.innerHTML = + 'Выберите строку в Таблице 1, чтобы увидеть MAC/IP за портом.'; setStatus( - `Связи для ${ip}: строк табл.1 ${deviceLinksRowsCache.length} (IF-MIB ${ifItems.length}, /links ${myLinks.length}), LLDP записей ${myLldp.length}.` + `Связи для ${ip}: строк табл.1 ${deviceLinksRowsCache.length} (IF-MIB ${ifItems.length}, /links ${myLinks.length}).` ); } catch (e) { deviceLinksTableBody.innerHTML = `${escapeHtml(e.message)}`; - deviceLldpTextTableBody.innerHTML = `${escapeHtml(e.message)}`; + deviceLldpTextTableBody.innerHTML = `${escapeHtml(e.message)}`; } } @@ -556,6 +565,18 @@ }); } + deviceLinksTableBody.addEventListener("click", (ev) => { + const tr = ev.target.closest("tr.port-row"); + if (!tr) return; + const idx = parseInt(tr.getAttribute("data-if-index") || "", 10); + if (!Number.isFinite(idx) || idx <= 0 || idx >= 1e9) { + setStatus("Для этой строки нет корректного ifIndex — выберите интерфейсную строку."); + return; + } + selectedIfIndex = idx; + loadPortDevicesForSelectedPort(); + }); + deviceTableBody.addEventListener("click", (ev) => { const tr = ev.target.closest("tr.device-row"); if (!tr) return;