feat(snmp,ui): устройства за портом (MAC/IP) в таблице 2

Добавил сбор FDB/ARP по SNMP (BRIDGE-MIB + ipNetToMedia), хранение и API /port-devices.
В UI Таблица 2 теперь показывает MAC/IP за выбранным портом после клика по строке Таблицы 1.

Made-with: Cursor
This commit is contained in:
Луценко Андрей Анатольевич
2026-04-10 17:44:42 +10:00
parent 526c67b5b1
commit 5b82901565
7 changed files with 350 additions and 39 deletions
+55 -34
View File
@@ -91,7 +91,7 @@
<div class="panel" id="deviceDetailPanel">
<strong>Связи выбранного устройства: <span id="selectedDeviceLabel"></span></strong>
<p class="hint" style="margin:6px 0 10px;">Кликните по строке в таблице «Найденные устройства». Таблица 1 — все интерфейсы из IF-MIB (SNMP) и LLDP-соседи по ifIndex; сортировка по «Локальный порт». Таблица 2 — сырые строки LLDP.</p>
<p class="hint" style="margin:6px 0 10px;">Кликните по строке в таблице «Найденные устройства». Таблица 1 — все интерфейсы из IF-MIB (SNMP) и LLDP-соседи по ifIndex; сортировка по «Локальный порт». Клик по строке Таблицы 1 загружает Таблицу 2 (MAC/IP за выбранным портом).</p>
<div style="margin-bottom: 14px;">
<div style="font-weight:600; margin-bottom:4px; color:#334155;">Таблица 1. Связи (IF-MIB + LLDP по портам)</div>
@@ -116,17 +116,18 @@
</div>
<div>
<div style="font-weight:600; margin-bottom:4px; color:#334155;">Таблица 2. LLDP текстом (по одной строке на запись MIB)</div>
<div style="font-weight:600; margin-bottom:4px; color:#334155;">Таблица 2. Устройства за портом (MAC/IP по BRIDGE-MIB + ARP)</div>
<div class="subtable-wrap">
<table class="device-detail-table" id="deviceLldpTextTable" style="width:100%; border-collapse:collapse;">
<thead>
<tr style="background:#f1f5f9;">
<th style="text-align:left; width:2.5rem;"></th>
<th style="text-align:left;">Текст LLDP</th>
<th style="text-align:left;">ifIndex</th>
<th style="text-align:left;">MAC</th>
<th style="text-align:left;">IP</th>
</tr>
</thead>
<tbody id="deviceLldpTextTableBody">
<tr><td colspan="2" style="padding:10px;color:#64748b;">Выберите устройство выше.</td></tr>
<tr><td colspan="3" style="padding:10px;color:#64748b;">Выберите устройство и порт в Таблице 1.</td></tr>
</tbody>
</table>
</div>
@@ -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 =
'<tr><td colspan="7" style="padding:10px;color:#64748b;">Выберите устройство в таблице выше.</td></tr>';
deviceLldpTextTableBody.innerHTML =
'<tr><td colspan="2" style="padding:10px;color:#64748b;">Выберите устройство в таблице выше.</td></tr>';
'<tr><td colspan="3" style="padding:10px;color:#64748b;">Выберите устройство и порт в Таблице 1.</td></tr>';
updateDeviceLinksSortIndicators();
}
@@ -464,19 +467,37 @@
deviceLinksTableBody.innerHTML = arr
.map(
(row) =>
`<tr><td>${escapeHtml(row.localPort)}</td><td style="font-size:12px;color:#475569;">${escapeHtml(row.snmpInfo)}</td><td>${escapeHtml(row.targetName)}</td><td>${escapeHtml(row.targetIP)}</td><td>${escapeHtml(row.targetPort)}</td><td>${escapeHtml(row.chassis)}</td><td>${escapeHtml(row.resolved)}</td></tr>`
`<tr class="port-row" data-if-index="${escapeHtml(row.sortKey)}"><td>${escapeHtml(row.localPort)}</td><td style="font-size:12px;color:#475569;">${escapeHtml(row.snmpInfo)}</td><td>${escapeHtml(row.targetName)}</td><td>${escapeHtml(row.targetIP)}</td><td>${escapeHtml(row.targetPort)}</td><td>${escapeHtml(row.chassis)}</td><td>${escapeHtml(row.resolved)}</td></tr>`
)
.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 = '<tr><td colspan="3" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
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 =
'<tr><td colspan="3" style="padding:10px;color:#64748b;">За этим портом MAC/IP не найдены (или устройство не отдаёт BRIDGE-MIB/ARP).</td></tr>';
return;
}
deviceLldpTextTableBody.innerHTML = items
.map(
(row) =>
`<tr><td>${escapeHtml(row.if_index)}</td><td>${escapeHtml(row.mac || "—")}</td><td>${escapeHtml(row.learned_ip || "—")}</td></tr>`
)
.join("");
} catch (e) {
deviceLldpTextTableBody.innerHTML = `<tr><td colspan="3" style="padding:10px;color:#b91c1c;">${escapeHtml(
e.message
)}</td></tr>`;
}
}
async function selectDeviceAndLoadLinks(ip) {
@@ -490,26 +511,22 @@
renderDeviceTableBody();
deviceLinksTableBody.innerHTML = '<tr><td colspan="7" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
deviceLldpTextTableBody.innerHTML = '<tr><td colspan="2" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
deviceLldpTextTableBody.innerHTML = '<tr><td colspan="3" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
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 =
'<tr><td colspan="2" style="padding:10px;color:#64748b;">Нет сырых записей LLDP для этого IP.</td></tr>';
} else {
deviceLldpTextTableBody.innerHTML = myLldp
.map(
(row, i) =>
`<tr><td style="vertical-align:top;color:#64748b;">${i + 1}</td><td style="white-space:pre-wrap;">${escapeHtml(formatLldpTextLine(row))}</td></tr>`
)
.join("");
}
selectedIfIndex = null;
deviceLldpTextTableBody.innerHTML =
'<tr><td colspan="3" style="padding:10px;color:#64748b;">Выберите строку в Таблице 1, чтобы увидеть MAC/IP за портом.</td></tr>';
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 = `<tr><td colspan="7" style="padding:10px;color:#b91c1c;">${escapeHtml(e.message)}</td></tr>`;
deviceLldpTextTableBody.innerHTML = `<tr><td colspan="2" style="padding:10px;color:#b91c1c;">${escapeHtml(e.message)}</td></tr>`;
deviceLldpTextTableBody.innerHTML = `<tr><td colspan="3" style="padding:10px;color:#b91c1c;">${escapeHtml(e.message)}</td></tr>`;
}
}
@@ -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;