From 7e79072619c5e25fb815d300b9bc703d105a6d0d 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 13:13:30 +1000 Subject: [PATCH] UI: click device row to show links table and LLDP text table. Made-with: Cursor --- internal/webui/index.html | 157 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 152 insertions(+), 5 deletions(-) diff --git a/internal/webui/index.html b/internal/webui/index.html index 146d70c..1be51ed 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -21,6 +21,11 @@ #edgeListPanel { margin-top: 12px; font-size: 13px; } #edgeListPanel summary { cursor: pointer; color: #334155; font-weight: 600; } #edgeListPre { max-height: 200px; overflow: auto; background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 8px; padding: 8px; margin-top: 6px; white-space: pre-wrap; word-break: break-all; } + 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; } + .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; } @@ -55,7 +60,7 @@
Найденные устройства - отвечают на ping; имя — из SNMP при успехе. Связи между устройствами — в графе («Загрузить»). + отвечают на ping; клик по строке — связи этого устройства в таблицах ниже. Граф — «Загрузить».
@@ -75,6 +80,49 @@
+
+ Связи выбранного устройства: +

Кликните по строке в таблице «Найденные устройства». Первая таблица — связи с попыткой сопоставить IP соседа (данные /links); вторая — каждая запись LLDP одной текстовой строкой.

+ +
+
Таблица 1. Связи (порты, сосед, IP если найден)
+
+ + + + + + + + + + + + + + +
Локальный портИмя соседа (sysName)IP соседаПорт соседаChassis IDСопоставление
Выберите устройство выше.
+
+
+ +
+
Таблица 2. LLDP текстом (по одной строке на запись MIB)
+
+ + + + + + + + + + +
Текст LLDP
Выберите устройство выше.
+
+
+
+
Легенда: ● синий - обычный узел @@ -128,6 +176,7 @@ /** @type {{ip:string,ping:string,name:string,snmp:string}[]} */ let deviceRowsCache = []; let deviceSort = { key: "ip", dir: 1 }; + let selectedDeviceIP = null; let lastGraphViewBox = "0 0 1200 620"; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -237,13 +286,108 @@ } const sorted = [...deviceRowsCache].sort((a, b) => deviceSort.dir * compareRows(a, b, deviceSort.key)); deviceTableBody.innerHTML = sorted - .map( - (r) => - `${escapeHtml(r.ip)}${escapeHtml(r.ping)}${escapeHtml(r.name)}${escapeHtml(r.snmp)}` - ) + .map((r) => { + const dip = escapeHtml(r.ip); + return `${dip}${escapeHtml(r.ping)}${escapeHtml(r.name)}${escapeHtml(r.snmp)}`; + }) .join(""); + deviceTableBody.querySelectorAll("tr.device-row").forEach((tr) => { + if (tr.getAttribute("data-ip") === selectedDeviceIP) tr.classList.add("selected"); + }); } + const deviceLinksTableBody = document.getElementById("deviceLinksTableBody"); + const deviceLldpTextTableBody = document.getElementById("deviceLldpTextTableBody"); + const selectedDeviceLabel = document.getElementById("selectedDeviceLabel"); + + function clearDeviceDetailPanel() { + selectedDeviceIP = null; + selectedDeviceLabel.textContent = "—"; + deviceLinksTableBody.innerHTML = + 'Выберите устройство в таблице выше.'; + deviceLldpTextTableBody.innerHTML = + 'Выберите устройство в таблице выше.'; + } + + function resolvedLabelRu(resolvedBy) { + if (resolvedBy === "remote_sys_name") return "по sysName → IP в скане"; + if (resolvedBy === "unresolved") return "IP не найден среди просканированных"; + return resolvedBy || "—"; + } + + 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 selectDeviceAndLoadLinks(ip) { + const scanId = scanIdInput.value.trim(); + if (!scanId) { + setStatus("Укажите scan_id, чтобы загрузить связи."); + return; + } + selectedDeviceIP = ip; + selectedDeviceLabel.textContent = ip; + renderDeviceTableBody(); + + deviceLinksTableBody.innerHTML = 'Загрузка…'; + deviceLldpTextTableBody.innerHTML = 'Загрузка…'; + + try { + const [linksRes, lldpRes] = await Promise.all([ + fetch(`/api/scans/${encodeURIComponent(scanId)}/links?limit=10000`), + fetch(`/api/scans/${encodeURIComponent(scanId)}/lldp?limit=10000`), + ]); + 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 || []; + const myLinks = linkItems.filter((x) => x.source_ip === ip); + const myLldp = lldpItems.filter((x) => x.ip === ip); + + if (myLinks.length === 0) { + deviceLinksTableBody.innerHTML = + 'Нет записей /links для этого IP (нет LLDP-соседей или скан без SNMP/LLDP).'; + } else { + deviceLinksTableBody.innerHTML = myLinks + .map( + (L) => + `${escapeHtml(L.source_port || "—")}${escapeHtml(L.target_sys_name || "—")}${escapeHtml(L.target_ip || "—")}${escapeHtml(L.target_port || "—")}${escapeHtml(L.remote_chassis_id || "—")}${escapeHtml(resolvedLabelRu(L.resolved_by))}` + ) + .join(""); + } + + if (myLldp.length === 0) { + deviceLldpTextTableBody.innerHTML = + 'Нет сырых записей LLDP для этого IP.'; + } else { + deviceLldpTextTableBody.innerHTML = myLldp + .map( + (row, i) => + `${i + 1}${escapeHtml(formatLldpTextLine(row))}` + ) + .join(""); + } + setStatus(`Связи для ${ip}: /links ${myLinks.length}, запись(ей) LLDP ${myLldp.length}.`); + } catch (e) { + deviceLinksTableBody.innerHTML = `${escapeHtml(e.message)}`; + deviceLldpTextTableBody.innerHTML = `${escapeHtml(e.message)}`; + } + } + + deviceTableBody.addEventListener("click", (ev) => { + const tr = ev.target.closest("tr.device-row"); + if (!tr) return; + const ip = tr.getAttribute("data-ip"); + if (!ip) return; + selectDeviceAndLoadLinks(ip); + }); + deviceTableHead.addEventListener("click", (ev) => { const th = ev.target.closest("[data-sort]"); if (!th || !deviceTableHead.contains(th)) return; @@ -260,10 +404,12 @@ async function loadDeviceTable(scanId) { if (!scanId) { deviceRowsCache = []; + clearDeviceDetailPanel(); deviceTableBody.innerHTML = 'Нет scan_id.'; updateDeviceSortIndicators(); return; } + clearDeviceDetailPanel(); deviceTableBody.innerHTML = 'Загрузка…'; try { const [hRes, sRes] = await Promise.all([ @@ -289,6 +435,7 @@ renderDeviceTableBody(); } catch (e) { deviceRowsCache = []; + clearDeviceDetailPanel(); deviceTableBody.innerHTML = `Ошибка: ${escapeHtml(e.message)}`; updateDeviceSortIndicators(); }