From 927110167c95d513ce851eb76a9260d6077dcebf Mon Sep 17 00:00:00 2001 From: PTah Date: Mon, 13 Apr 2026 12:15:54 +1000 Subject: [PATCH] feat(ui): highlight port/MAC rows, FDB MAC/IP columns in table 1 Made-with: Cursor --- internal/webui/index.html | 149 ++++++++++++++++++++++++++++++-------- 1 file changed, 120 insertions(+), 29 deletions(-) diff --git a/internal/webui/index.html b/internal/webui/index.html index bedd509..c7fbc04 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -24,6 +24,12 @@ tr.device-row { cursor: pointer; } tr.device-row:hover { background: #f1f5f9; } tr.device-row.selected { background: #dbeafe; box-shadow: inset 0.15em 0 0 #2563eb; } + tr.port-row { cursor: pointer; } + tr.port-row:hover { background: #f1f5f9; } + tr.port-row.selected { background: #dbeafe; box-shadow: inset 0.15em 0 0 #2563eb; } + tr.fdb-row { cursor: pointer; } + tr.fdb-row:hover { background: #f1f5f9; } + tr.fdb-row.selected { background: #dbeafe; box-shadow: inset 0.15em 0 0 #2563eb; } .device-detail-table th, .device-detail-table td { border-bottom: 1px solid #e5e7eb; padding: 6px 8px; font-size: 13px; } .subtable-wrap { overflow: auto; max-height: 200px; margin-top: 6px; border: 1px solid #e5e7eb; border-radius: 8px; } @@ -91,7 +97,7 @@
Связи выбранного устройства: -

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

+

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

Таблица 1. Связи (IF-MIB + LLDP по портам)
@@ -101,6 +107,8 @@ Локальный порт SNMP (интерфейс) + MAC за портом + IP (ARP) Имя соседа (sysName) IP соседа Порт соседа @@ -109,7 +117,7 @@ - Выберите устройство выше. + Выберите устройство выше.
@@ -189,10 +197,13 @@ let deviceSort = { key: "ip", dir: 1 }; let selectedDeviceIP = null; let lastGraphViewBox = "0 0 1200 620"; - /** @type {{sortKey:number,localPort:string,snmpInfo:string,targetName:string,targetIP:string,targetPort:string,chassis:string,resolved:string}[]} */ + /** @type {{sortKey:number,localPort:string,snmpInfo:string,fdbMac:string,fdbIp:string,targetName:string,targetIP:string,targetPort:string,chassis:string,resolved:string}[]} */ let deviceLinksRowsCache = []; let deviceLinksSort = { key: "port", dir: 1 }; let selectedIfIndex = null; + /** @type {{if_index:number,bridge_port:number,mac:string,learned_ip:string}[]} */ + let lastPortDevItems = []; + let selectedFdbKey = null; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -323,10 +334,12 @@ function clearDeviceDetailPanel() { selectedDeviceIP = null; selectedIfIndex = null; + lastPortDevItems = []; + selectedFdbKey = null; selectedDeviceLabel.textContent = "—"; deviceLinksRowsCache = []; deviceLinksTableBody.innerHTML = - 'Выберите устройство в таблице выше.'; + 'Выберите устройство в таблице выше.'; deviceLldpTextTableBody.innerHTML = 'Выберите устройство и порт в Таблице 1.'; updateDeviceLinksSortIndicators(); @@ -354,6 +367,50 @@ return m ? parseInt(m[1], 10) : null; } + function normalizeMacLocal(m) { + return String(m || "") + .toLowerCase() + .replace(/-/g, ":") + .trim(); + } + + function aggregatePortFdbSummary(sortKey, portItems) { + if (!portItems || !sortKey || sortKey <= 0 || sortKey >= 1e9) { + return { macText: "—", ipText: "—" }; + } + const hits = []; + for (const p of portItems) { + const br = Number(p.bridge_port) || 0; + const ifi = Number(p.if_index) || 0; + if (ifi === sortKey || br === sortKey) hits.push(p); + } + const byMac = new Map(); + for (const p of hits) { + const mk = normalizeMacLocal(p.mac); + if (!mk) continue; + const ip = String(p.learned_ip || "").trim(); + const cur = byMac.get(mk); + const macDisp = String(p.mac || "").trim() || mk; + if (!cur || (ip && !cur.ip)) byMac.set(mk, { mac: macDisp, ip }); + } + const rows = [...byMac.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 || "—" }; + } + return { macText: `несколько MAC (${n})`, ipText: "—" }; + } + + function attachFdbSummaries(rows, portItems) { + for (const r of rows) { + const s = aggregatePortFdbSummary(r.sortKey, portItems); + r.fdbMac = s.macText; + r.fdbIp = s.ipText; + } + } + function buildMergedPortRows(interfaces, links) { const byIdx = new Map(); for (const L of links) { @@ -449,6 +506,13 @@ }); } + function updatePortRowSelectionHighlight() { + deviceLinksTableBody.querySelectorAll("tr.port-row").forEach((tr) => { + const idx = parseInt(tr.getAttribute("data-if-index") || "", 10); + tr.classList.toggle("selected", selectedIfIndex != null && Number.isFinite(idx) && idx === selectedIfIndex); + }); + } + function renderDeviceLinksTableBody() { const arr = [...deviceLinksRowsCache]; arr.sort((a, b) => { @@ -461,38 +525,48 @@ }); if (arr.length === 0) { deviceLinksTableBody.innerHTML = - 'Нет строк для отображения.'; + 'Нет строк для отображения.'; return; } 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.fdbMac || "—")}${escapeHtml(row.fdbIp || "—")}${escapeHtml(row.targetName)}${escapeHtml(row.targetIP)}${escapeHtml(row.targetPort)}${escapeHtml(row.chassis)}${escapeHtml(row.resolved)}` ) .join(""); + updatePortRowSelectionHighlight(); + } + + function renderFdbTable2Body(items) { + selectedFdbKey = null; + if (!items || items.length === 0) { + deviceLldpTextTableBody.innerHTML = + 'За этим портом MAC/IP не найдены (или устройство не отдаёт BRIDGE-MIB/ARP).'; + return; + } + const sorted = [...items].sort((a, b) => { + const ma = String(a.mac || "").localeCompare(String(b.mac || "")); + if (ma !== 0) return ma; + return String(a.learned_ip || "").localeCompare(String(b.learned_ip || "")); + }); + deviceLldpTextTableBody.innerHTML = sorted + .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 || "—")}`; + }) + .join(""); } - async function loadPortDevicesForSelectedPort() { - const scanId = scanIdInput.value.trim(); - if (!scanId || !selectedDeviceIP || !selectedIfIndex) return; + function loadPortDevicesForSelectedPort() { + if (!selectedDeviceIP || selectedIfIndex == null) return; deviceLldpTextTableBody.innerHTML = 'Загрузка…'; try { - const res = await fetch( - `/api/scans/${encodeURIComponent(scanId)}/port-devices?ip=${encodeURIComponent(selectedDeviceIP)}&if_index=${encodeURIComponent(selectedIfIndex)}&limit=20000` + const items = lastPortDevItems.filter( + (p) => Number(p.if_index) === selectedIfIndex || Number(p.bridge_port) === selectedIfIndex ); - 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(""); + renderFdbTable2Body(items); } catch (e) { deviceLldpTextTableBody.innerHTML = `${escapeHtml( e.message @@ -510,15 +584,18 @@ selectedDeviceLabel.textContent = ip; renderDeviceTableBody(); - deviceLinksTableBody.innerHTML = 'Загрузка…'; + deviceLinksTableBody.innerHTML = 'Загрузка…'; deviceLldpTextTableBody.innerHTML = 'Загрузка…'; try { - const [linksRes, ifRes] = await Promise.all([ + const [linksRes, ifRes, pdRes] = await Promise.all([ fetch(`/api/scans/${encodeURIComponent(scanId)}/links?limit=20000`), fetch( `/api/scans/${encodeURIComponent(scanId)}/interfaces?ip=${encodeURIComponent(ip)}&limit=8192` ), + fetch( + `/api/scans/${encodeURIComponent(scanId)}/port-devices?ip=${encodeURIComponent(ip)}&limit=20000` + ), ]); if (!linksRes.ok) throw new Error("links: " + (await linksRes.text())); const linkItems = (await linksRes.json()).items || []; @@ -526,25 +603,28 @@ if (ifRes.ok) { ifItems = ((await ifRes.json()) || {}).items || []; } + lastPortDevItems = pdRes.ok ? ((await pdRes.json()) || {}).items || [] : []; const myLinks = linkItems.filter((x) => x.source_ip === ip); deviceLinksRowsCache = buildMergedPortRows(ifItems, myLinks); + attachFdbSummaries(deviceLinksRowsCache, lastPortDevItems); updateDeviceLinksSortIndicators(); if (deviceLinksRowsCache.length === 0) { deviceLinksTableBody.innerHTML = - 'Нет данных: для этого IP нет интерфейсов IF-MIB и нет строк /links (пересканируйте после обновления сервера для сбора IF-MIB).'; + 'Нет данных: для этого IP нет интерфейсов IF-MIB и нет строк /links (пересканируйте после обновления сервера для сбора IF-MIB).'; } else { renderDeviceLinksTableBody(); } selectedIfIndex = null; + selectedFdbKey = null; deviceLldpTextTableBody.innerHTML = 'Выберите строку в Таблице 1, чтобы увидеть MAC/IP за портом.'; setStatus( - `Связи для ${ip}: строк табл.1 ${deviceLinksRowsCache.length} (IF-MIB ${ifItems.length}, /links ${myLinks.length}).` + `Связи для ${ip}: строк табл.1 ${deviceLinksRowsCache.length} (IF-MIB ${ifItems.length}, /links ${myLinks.length}, FDB строк ${lastPortDevItems.length}).` ); } catch (e) { - deviceLinksTableBody.innerHTML = `${escapeHtml(e.message)}`; + deviceLinksTableBody.innerHTML = `${escapeHtml(e.message)}`; deviceLldpTextTableBody.innerHTML = `${escapeHtml(e.message)}`; } } @@ -575,6 +655,17 @@ } selectedIfIndex = idx; loadPortDevicesForSelectedPort(); + updatePortRowSelectionHighlight(); + }); + + deviceLldpTextTableBody.addEventListener("click", (ev) => { + const tr = ev.target.closest("tr.fdb-row"); + if (!tr) return; + const k = tr.getAttribute("data-fdb-key") || ""; + selectedFdbKey = k; + deviceLldpTextTableBody.querySelectorAll("tr.fdb-row").forEach((r) => { + r.classList.toggle("selected", (r.getAttribute("data-fdb-key") || "") === k); + }); }); deviceTableBody.addEventListener("click", (ev) => {