UI: click device row to show links table and LLDP text table.
Made-with: Cursor
This commit is contained in:
+152
-5
@@ -21,6 +21,11 @@
|
|||||||
#edgeListPanel { margin-top: 12px; font-size: 13px; }
|
#edgeListPanel { margin-top: 12px; font-size: 13px; }
|
||||||
#edgeListPanel summary { cursor: pointer; color: #334155; font-weight: 600; }
|
#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; }
|
#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; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -55,7 +60,7 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="row" style="margin-bottom: 6px;">
|
<div class="row" style="margin-bottom: 6px;">
|
||||||
<strong>Найденные устройства</strong>
|
<strong>Найденные устройства</strong>
|
||||||
<span class="hint" style="margin:0;">отвечают на ping; имя — из SNMP при успехе. Связи между устройствами — в графе («Загрузить»).</span>
|
<span class="hint" style="margin:0;">отвечают на ping; клик по строке — связи этого устройства в таблицах ниже. Граф — «Загрузить».</span>
|
||||||
<button type="button" id="refreshDevicesBtn">Обновить список</button>
|
<button type="button" id="refreshDevicesBtn">Обновить список</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="overflow:auto; max-height: 240px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
<div style="overflow:auto; max-height: 240px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
@@ -75,6 +80,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" id="deviceDetailPanel">
|
||||||
|
<strong>Связи выбранного устройства: <span id="selectedDeviceLabel">—</span></strong>
|
||||||
|
<p class="hint" style="margin:6px 0 10px;">Кликните по строке в таблице «Найденные устройства». Первая таблица — связи с попыткой сопоставить IP соседа (данные /links); вторая — каждая запись LLDP одной текстовой строкой.</p>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 14px;">
|
||||||
|
<div style="font-weight:600; margin-bottom:4px; color:#334155;">Таблица 1. Связи (порты, сосед, IP если найден)</div>
|
||||||
|
<div class="subtable-wrap">
|
||||||
|
<table class="device-detail-table" id="deviceLinksTable" style="width:100%; border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f1f5f9;">
|
||||||
|
<th style="text-align:left;">Локальный порт</th>
|
||||||
|
<th style="text-align:left;">Имя соседа (sysName)</th>
|
||||||
|
<th style="text-align:left;">IP соседа</th>
|
||||||
|
<th style="text-align:left;">Порт соседа</th>
|
||||||
|
<th style="text-align:left;">Chassis ID</th>
|
||||||
|
<th style="text-align:left;">Сопоставление</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="deviceLinksTableBody">
|
||||||
|
<tr><td colspan="6" style="padding:10px;color:#64748b;">Выберите устройство выше.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600; margin-bottom:4px; color:#334155;">Таблица 2. LLDP текстом (по одной строке на запись MIB)</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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="deviceLldpTextTableBody">
|
||||||
|
<tr><td colspan="2" style="padding:10px;color:#64748b;">Выберите устройство выше.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="margin: 6px 0 10px 0; font-size: 13px;">
|
<div style="margin: 6px 0 10px 0; font-size: 13px;">
|
||||||
Легенда:
|
Легенда:
|
||||||
<span style="display:inline-block;margin-left:8px;color:#2563eb;">● синий - обычный узел</span>
|
<span style="display:inline-block;margin-left:8px;color:#2563eb;">● синий - обычный узел</span>
|
||||||
@@ -128,6 +176,7 @@
|
|||||||
/** @type {{ip:string,ping:string,name:string,snmp:string}[]} */
|
/** @type {{ip:string,ping:string,name:string,snmp:string}[]} */
|
||||||
let deviceRowsCache = [];
|
let deviceRowsCache = [];
|
||||||
let deviceSort = { key: "ip", dir: 1 };
|
let deviceSort = { key: "ip", dir: 1 };
|
||||||
|
let selectedDeviceIP = null;
|
||||||
let lastGraphViewBox = "0 0 1200 620";
|
let lastGraphViewBox = "0 0 1200 620";
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
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));
|
const sorted = [...deviceRowsCache].sort((a, b) => deviceSort.dir * compareRows(a, b, deviceSort.key));
|
||||||
deviceTableBody.innerHTML = sorted
|
deviceTableBody.innerHTML = sorted
|
||||||
.map(
|
.map((r) => {
|
||||||
(r) =>
|
const dip = escapeHtml(r.ip);
|
||||||
`<tr><td>${escapeHtml(r.ip)}</td><td>${escapeHtml(r.ping)}</td><td>${escapeHtml(r.name)}</td><td>${escapeHtml(r.snmp)}</td></tr>`
|
return `<tr class="device-row" data-ip="${dip}" title="Показать связи этого устройства"><td>${dip}</td><td>${escapeHtml(r.ping)}</td><td>${escapeHtml(r.name)}</td><td>${escapeHtml(r.snmp)}</td></tr>`;
|
||||||
)
|
})
|
||||||
.join("");
|
.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 =
|
||||||
|
'<tr><td colspan="6" style="padding:10px;color:#64748b;">Выберите устройство в таблице выше.</td></tr>';
|
||||||
|
deviceLldpTextTableBody.innerHTML =
|
||||||
|
'<tr><td colspan="2" style="padding:10px;color:#64748b;">Выберите устройство в таблице выше.</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<tr><td colspan="6" 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`),
|
||||||
|
]);
|
||||||
|
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 =
|
||||||
|
'<tr><td colspan="6" style="padding:10px;color:#64748b;">Нет записей /links для этого IP (нет LLDP-соседей или скан без SNMP/LLDP).</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("");
|
||||||
|
}
|
||||||
|
|
||||||
|
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("");
|
||||||
|
}
|
||||||
|
setStatus(`Связи для ${ip}: /links ${myLinks.length}, запись(ей) LLDP ${myLldp.length}.`);
|
||||||
|
} catch (e) {
|
||||||
|
deviceLinksTableBody.innerHTML = `<tr><td colspan="6" 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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
deviceTableHead.addEventListener("click", (ev) => {
|
||||||
const th = ev.target.closest("[data-sort]");
|
const th = ev.target.closest("[data-sort]");
|
||||||
if (!th || !deviceTableHead.contains(th)) return;
|
if (!th || !deviceTableHead.contains(th)) return;
|
||||||
@@ -260,10 +404,12 @@
|
|||||||
async function loadDeviceTable(scanId) {
|
async function loadDeviceTable(scanId) {
|
||||||
if (!scanId) {
|
if (!scanId) {
|
||||||
deviceRowsCache = [];
|
deviceRowsCache = [];
|
||||||
|
clearDeviceDetailPanel();
|
||||||
deviceTableBody.innerHTML = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Нет scan_id.</td></tr>';
|
deviceTableBody.innerHTML = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Нет scan_id.</td></tr>';
|
||||||
updateDeviceSortIndicators();
|
updateDeviceSortIndicators();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
clearDeviceDetailPanel();
|
||||||
deviceTableBody.innerHTML = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
|
deviceTableBody.innerHTML = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
|
||||||
try {
|
try {
|
||||||
const [hRes, sRes] = await Promise.all([
|
const [hRes, sRes] = await Promise.all([
|
||||||
@@ -289,6 +435,7 @@
|
|||||||
renderDeviceTableBody();
|
renderDeviceTableBody();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
deviceRowsCache = [];
|
deviceRowsCache = [];
|
||||||
|
clearDeviceDetailPanel();
|
||||||
deviceTableBody.innerHTML = `<tr><td colspan="4" style="padding:10px;color:#b91c1c;">Ошибка: ${escapeHtml(e.message)}</td></tr>`;
|
deviceTableBody.innerHTML = `<tr><td colspan="4" style="padding:10px;color:#b91c1c;">Ошибка: ${escapeHtml(e.message)}</td></tr>`;
|
||||||
updateDeviceSortIndicators();
|
updateDeviceSortIndicators();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user