feat(ui,snmp): IF-MIB интерфейсы в табл.1 связей, слияние с LLDP, сортировка по локальному порту
Made-with: Cursor
This commit is contained in:
+183
-20
@@ -83,15 +83,16 @@
|
||||
|
||||
<div class="panel" id="deviceDetailPanel">
|
||||
<strong>Связи выбранного устройства: <span id="selectedDeviceLabel">—</span></strong>
|
||||
<p class="hint" style="margin:6px 0 10px;">Кликните по строке в таблице «Найденные устройства». Первая таблица — связи с попыткой сопоставить IP соседа (данные /links); вторая — каждая запись LLDP одной текстовой строкой.</p>
|
||||
<p class="hint" style="margin:6px 0 10px;">Кликните по строке в таблице «Найденные устройства». Таблица 1 — все интерфейсы из IF-MIB (SNMP) и LLDP-соседи по ifIndex; сортировка по «Локальный порт». Таблица 2 — сырые строки LLDP.</p>
|
||||
|
||||
<div style="margin-bottom: 14px;">
|
||||
<div style="font-weight:600; margin-bottom:4px; color:#334155;">Таблица 1. Связи (порты, сосед, IP если найден)</div>
|
||||
<div style="font-weight:600; margin-bottom:4px; color:#334155;">Таблица 1. Связи (IF-MIB + LLDP по портам)</div>
|
||||
<div class="subtable-wrap">
|
||||
<table class="device-detail-table" id="deviceLinksTable" style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<thead id="deviceLinksTableHead">
|
||||
<tr style="background:#f1f5f9;">
|
||||
<th style="text-align:left;">Локальный порт</th>
|
||||
<th class="sortable-th-link" data-sort-link="port" style="text-align:left; cursor:pointer;">Локальный порт <span class="sort-ind-link"></span></th>
|
||||
<th style="text-align:left;">SNMP (интерфейс)</th>
|
||||
<th style="text-align:left;">Имя соседа (sysName)</th>
|
||||
<th style="text-align:left;">IP соседа</th>
|
||||
<th style="text-align:left;">Порт соседа</th>
|
||||
@@ -100,7 +101,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="deviceLinksTableBody">
|
||||
<tr><td colspan="6" style="padding:10px;color:#64748b;">Выберите устройство выше.</td></tr>
|
||||
<tr><td colspan="7" style="padding:10px;color:#64748b;">Выберите устройство выше.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -179,6 +180,9 @@
|
||||
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}[]} */
|
||||
let deviceLinksRowsCache = [];
|
||||
let deviceLinksSort = { key: "port", dir: 1 };
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -302,16 +306,19 @@
|
||||
}
|
||||
|
||||
const deviceLinksTableBody = document.getElementById("deviceLinksTableBody");
|
||||
const deviceLinksTableHead = document.getElementById("deviceLinksTableHead");
|
||||
const deviceLldpTextTableBody = document.getElementById("deviceLldpTextTableBody");
|
||||
const selectedDeviceLabel = document.getElementById("selectedDeviceLabel");
|
||||
|
||||
function clearDeviceDetailPanel() {
|
||||
selectedDeviceIP = null;
|
||||
selectedDeviceLabel.textContent = "—";
|
||||
deviceLinksRowsCache = [];
|
||||
deviceLinksTableBody.innerHTML =
|
||||
'<tr><td colspan="6" style="padding:10px;color:#64748b;">Выберите устройство в таблице выше.</td></tr>';
|
||||
'<tr><td colspan="7" style="padding:10px;color:#64748b;">Выберите устройство в таблице выше.</td></tr>';
|
||||
deviceLldpTextTableBody.innerHTML =
|
||||
'<tr><td colspan="2" style="padding:10px;color:#64748b;">Выберите устройство в таблице выше.</td></tr>';
|
||||
updateDeviceLinksSortIndicators();
|
||||
}
|
||||
|
||||
function resolvedLabelRu(resolvedBy) {
|
||||
@@ -320,6 +327,140 @@
|
||||
return resolvedBy || "—";
|
||||
}
|
||||
|
||||
function formatSnmpIfaceRow(i) {
|
||||
const parts = [`ifIndex ${i.if_index}`];
|
||||
if (i.if_name) parts.push(i.if_name);
|
||||
if (i.if_descr) parts.push(i.if_descr);
|
||||
if (i.if_oper_status) parts.push(`(${i.if_oper_status})`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** Числовой ifIndex из локального порта LLDP (префикс до « — » или «-»). */
|
||||
function parseLldpLocalIfIndex(sourcePort) {
|
||||
if (!sourcePort) return null;
|
||||
const head = String(sourcePort).trim().split(/\s*[\u2014\-]\s*/)[0].trim();
|
||||
const m = head.match(/^(\d+)/);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
}
|
||||
|
||||
function buildMergedPortRows(interfaces, links) {
|
||||
const byIdx = new Map();
|
||||
for (const L of links) {
|
||||
const idx = parseLldpLocalIfIndex(L.source_port);
|
||||
if (idx == null || !Number.isFinite(idx)) continue;
|
||||
if (!byIdx.has(idx)) byIdx.set(idx, []);
|
||||
byIdx.get(idx).push(L);
|
||||
}
|
||||
const rows = [];
|
||||
const seenIfaceIdx = new Set();
|
||||
|
||||
if (interfaces && interfaces.length > 0) {
|
||||
const sortedIf = [...interfaces].sort((a, b) => a.if_index - b.if_index);
|
||||
for (const iface of sortedIf) {
|
||||
const idx = iface.if_index;
|
||||
seenIfaceIdx.add(idx);
|
||||
const snmpText = formatSnmpIfaceRow(iface);
|
||||
const ll = byIdx.get(idx) || [];
|
||||
const localLabel = iface.if_name || iface.if_descr || `if${idx}`;
|
||||
if (ll.length === 0) {
|
||||
rows.push({
|
||||
sortKey: idx,
|
||||
localPort: localLabel,
|
||||
snmpInfo: snmpText,
|
||||
targetName: "—",
|
||||
targetIP: "—",
|
||||
targetPort: "—",
|
||||
chassis: "—",
|
||||
resolved: "нет LLDP на этом ifIndex",
|
||||
});
|
||||
} else {
|
||||
for (const L of ll) {
|
||||
rows.push({
|
||||
sortKey: idx,
|
||||
localPort: L.source_port || localLabel,
|
||||
snmpInfo: snmpText,
|
||||
targetName: L.target_sys_name || "—",
|
||||
targetIP: L.target_ip || "—",
|
||||
targetPort: L.target_port || "—",
|
||||
chassis: L.remote_chassis_id || "—",
|
||||
resolved: resolvedLabelRu(L.resolved_by),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [idx, list] of byIdx) {
|
||||
if (interfaces && interfaces.length > 0 && seenIfaceIdx.has(idx)) continue;
|
||||
for (const L of list) {
|
||||
rows.push({
|
||||
sortKey: idx,
|
||||
localPort: L.source_port || `if${idx}`,
|
||||
snmpInfo: "—",
|
||||
targetName: L.target_sys_name || "—",
|
||||
targetIP: L.target_ip || "—",
|
||||
targetPort: L.target_port || "—",
|
||||
chassis: L.remote_chassis_id || "—",
|
||||
resolved: resolvedLabelRu(L.resolved_by),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const L of links) {
|
||||
const idx = parseLldpLocalIfIndex(L.source_port);
|
||||
if (idx != null && Number.isFinite(idx)) continue;
|
||||
rows.push({
|
||||
sortKey: 1e9,
|
||||
localPort: L.source_port || "—",
|
||||
snmpInfo: "—",
|
||||
targetName: L.target_sys_name || "—",
|
||||
targetIP: L.target_ip || "—",
|
||||
targetPort: L.target_port || "—",
|
||||
chassis: L.remote_chassis_id || "—",
|
||||
resolved: resolvedLabelRu(L.resolved_by),
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function updateDeviceLinksSortIndicators() {
|
||||
if (!deviceLinksTableHead) return;
|
||||
deviceLinksTableHead.querySelectorAll("[data-sort-link]").forEach((th) => {
|
||||
const sp = th.querySelector(".sort-ind-link");
|
||||
if (!sp) return;
|
||||
const k = th.getAttribute("data-sort-link");
|
||||
if (deviceLinksSort.key !== k) {
|
||||
sp.textContent = "";
|
||||
return;
|
||||
}
|
||||
sp.textContent = deviceLinksSort.dir > 0 ? " ▲" : " ▼";
|
||||
});
|
||||
}
|
||||
|
||||
function renderDeviceLinksTableBody() {
|
||||
const arr = [...deviceLinksRowsCache];
|
||||
arr.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (deviceLinksSort.key === "port") {
|
||||
cmp = (a.sortKey || 0) - (b.sortKey || 0);
|
||||
if (cmp === 0) cmp = String(a.localPort).localeCompare(String(b.localPort), "ru", { sensitivity: "base" });
|
||||
}
|
||||
return cmp * deviceLinksSort.dir;
|
||||
});
|
||||
if (arr.length === 0) {
|
||||
deviceLinksTableBody.innerHTML =
|
||||
'<tr><td colspan="7" style="padding:10px;color:#64748b;">Нет строк для отображения.</td></tr>';
|
||||
return;
|
||||
}
|
||||
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>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function formatLldpTextLine(item) {
|
||||
const chunks = [];
|
||||
chunks.push(`Источник ${item.ip}`);
|
||||
@@ -340,31 +481,35 @@
|
||||
selectedDeviceLabel.textContent = ip;
|
||||
renderDeviceTableBody();
|
||||
|
||||
deviceLinksTableBody.innerHTML = '<tr><td colspan="6" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
|
||||
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>';
|
||||
|
||||
try {
|
||||
const [linksRes, lldpRes] = await Promise.all([
|
||||
fetch(`/api/scans/${encodeURIComponent(scanId)}/links?limit=10000`),
|
||||
fetch(`/api/scans/${encodeURIComponent(scanId)}/lldp?limit=10000`),
|
||||
const [linksRes, lldpRes, 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);
|
||||
|
||||
if (myLinks.length === 0) {
|
||||
deviceLinksRowsCache = buildMergedPortRows(ifItems, myLinks);
|
||||
updateDeviceLinksSortIndicators();
|
||||
if (deviceLinksRowsCache.length === 0) {
|
||||
deviceLinksTableBody.innerHTML =
|
||||
'<tr><td colspan="6" style="padding:10px;color:#64748b;">Нет записей /links для этого IP (нет LLDP-соседей или скан без SNMP/LLDP).</td></tr>';
|
||||
'<tr><td colspan="7" style="padding:10px;color:#64748b;">Нет данных: для этого IP нет интерфейсов IF-MIB и нет строк /links (пересканируйте после обновления сервера для сбора IF-MIB).</td></tr>';
|
||||
} else {
|
||||
deviceLinksTableBody.innerHTML = myLinks
|
||||
.map(
|
||||
(L) =>
|
||||
`<tr><td>${escapeHtml(L.source_port || "—")}</td><td>${escapeHtml(L.target_sys_name || "—")}</td><td>${escapeHtml(L.target_ip || "—")}</td><td>${escapeHtml(L.target_port || "—")}</td><td>${escapeHtml(L.remote_chassis_id || "—")}</td><td>${escapeHtml(resolvedLabelRu(L.resolved_by))}</td></tr>`
|
||||
)
|
||||
.join("");
|
||||
renderDeviceLinksTableBody();
|
||||
}
|
||||
|
||||
if (myLldp.length === 0) {
|
||||
@@ -378,13 +523,31 @@
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
setStatus(`Связи для ${ip}: /links ${myLinks.length}, запись(ей) LLDP ${myLldp.length}.`);
|
||||
setStatus(
|
||||
`Связи для ${ip}: строк табл.1 ${deviceLinksRowsCache.length} (IF-MIB ${ifItems.length}, /links ${myLinks.length}), LLDP записей ${myLldp.length}.`
|
||||
);
|
||||
} catch (e) {
|
||||
deviceLinksTableBody.innerHTML = `<tr><td colspan="6" style="padding:10px;color:#b91c1c;">${escapeHtml(e.message)}</td></tr>`;
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (deviceLinksTableHead) {
|
||||
deviceLinksTableHead.addEventListener("click", (ev) => {
|
||||
const th = ev.target.closest("[data-sort-link]");
|
||||
if (!th || !deviceLinksTableHead.contains(th)) return;
|
||||
const key = th.getAttribute("data-sort-link");
|
||||
if (!key) return;
|
||||
if (deviceLinksSort.key === key) deviceLinksSort.dir *= -1;
|
||||
else {
|
||||
deviceLinksSort.key = key;
|
||||
deviceLinksSort.dir = 1;
|
||||
}
|
||||
renderDeviceLinksTableBody();
|
||||
updateDeviceLinksSortIndicators();
|
||||
});
|
||||
}
|
||||
|
||||
deviceTableBody.addEventListener("click", (ev) => {
|
||||
const tr = ev.target.closest("tr.device-row");
|
||||
if (!tr) return;
|
||||
|
||||
Reference in New Issue
Block a user