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) => {