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();
}