UI: sortable device table columns, spring graph layout, edge arrows and link list.

Made-with: Cursor
This commit is contained in:
Луценко Андрей Анатольевич
2026-04-10 13:07:44 +10:00
parent 204b4f8768
commit 0f32e0004f
+314 -51
View File
@@ -15,11 +15,17 @@
.pipeline strong { color: #0f172a; } .pipeline strong { color: #0f172a; }
.panel { margin: 14px 0; } .panel { margin: 14px 0; }
#deviceTable th, #deviceTable td { border-bottom: 1px solid #e5e7eb; padding: 6px 8px; } #deviceTable th, #deviceTable td { border-bottom: 1px solid #e5e7eb; padding: 6px 8px; }
th.sortable-th { cursor: pointer; user-select: none; white-space: nowrap; }
th.sortable-th:hover { background: #e2e8f0; }
th.sortable-th .sort-ind { color: #64748b; font-size: 11px; margin-left: 4px; }
#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; }
</style> </style>
</head> </head>
<body> <body>
<h2>NetTopo: просмотр топологии</h2> <h2>NetTopo: просмотр топологии</h2>
<div class="hint">После скана нажмите «Загрузить», чтобы нарисовать граф по данным LLDP/SNMP.</div> <div class="hint">После скана нажмите «Загрузить»: граф раскладывается пружинной моделью по LLDP, стрелки — от локального порта к соседу; полный список связей — блок под графом. Таблица устройств: сортировка по заголовкам.</div>
<div class="pipeline"> <div class="pipeline">
<strong>Один scan = один полный проход по сети.</strong> Для каждого IP из CIDR (с исключениями): <strong>ping</strong> → для ответивших — проверка <strong>TCP-портов</strong> → если включён SNMP — опрос <strong>системы (sysName и др.)</strong> и сразу обход <strong>LLDP-MIB</strong> (соседи по портам). Отдельной кнопки «только LLDP» нет: соседи попадают в этот же цикл. Кнопка «Создать scan» ставит задачу и сама ждёт завершения, показывая прогресс в строке статуса ниже. <strong>Один scan = один полный проход по сети.</strong> Для каждого IP из CIDR (с исключениями): <strong>ping</strong> → для ответивших — проверка <strong>TCP-портов</strong> → если включён SNMP — опрос <strong>системы (sysName и др.)</strong> и сразу обход <strong>LLDP-MIB</strong> (соседи по портам). Отдельной кнопки «только LLDP» нет: соседи попадают в этот же цикл. Кнопка «Создать scan» ставит задачу и сама ждёт завершения, показывая прогресс в строке статуса ниже.
</div> </div>
@@ -54,12 +60,12 @@
</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;">
<table id="deviceTable" style="width:100%; border-collapse: collapse; font-size: 13px;"> <table id="deviceTable" style="width:100%; border-collapse: collapse; font-size: 13px;">
<thead> <thead id="deviceTableHead">
<tr style="background:#f1f5f9;"> <tr style="background:#f1f5f9;">
<th style="text-align:left;">IP</th> <th class="sortable-th" data-sort="ip" style="text-align:left;">IP <span class="sort-ind"></span></th>
<th style="text-align:left;">Ping</th> <th class="sortable-th" data-sort="ping" style="text-align:left;">Ping <span class="sort-ind"></span></th>
<th style="text-align:left;">Имя (SNMP)</th> <th class="sortable-th" data-sort="name" style="text-align:left;">Имя (SNMP) <span class="sort-ind"></span></th>
<th style="text-align:left;">SNMP</th> <th class="sortable-th" data-sort="snmp" style="text-align:left;">SNMP <span class="sort-ind"></span></th>
</tr> </tr>
</thead> </thead>
<tbody id="deviceTableBody"> <tbody id="deviceTableBody">
@@ -83,6 +89,12 @@
</div> </div>
<pre id="diffBox" style="background:#f8fafc;border:1px solid #e5e7eb;border-radius:8px;padding:10px;white-space:pre-wrap;"></pre> <pre id="diffBox" style="background:#f8fafc;border:1px solid #e5e7eb;border-radius:8px;padding:10px;white-space:pre-wrap;"></pre>
<svg id="canvas" viewBox="0 0 1200 620"></svg> <svg id="canvas" viewBox="0 0 1200 620"></svg>
<div id="edgeListPanel" style="display:none;">
<details open>
<summary>Связи LLDP (текстом, по портам)</summary>
<pre id="edgeListPre"></pre>
</details>
</div>
<script> <script>
const scanIdInput = document.getElementById("scanId"); const scanIdInput = document.getElementById("scanId");
@@ -105,11 +117,18 @@
const diffBox = document.getElementById("diffBox"); const diffBox = document.getElementById("diffBox");
const svg = document.getElementById("canvas"); const svg = document.getElementById("canvas");
const deviceTableBody = document.getElementById("deviceTableBody"); const deviceTableBody = document.getElementById("deviceTableBody");
const deviceTableHead = document.getElementById("deviceTableHead");
const refreshDevicesBtn = document.getElementById("refreshDevicesBtn"); const refreshDevicesBtn = document.getElementById("refreshDevicesBtn");
const edgeListPanel = document.getElementById("edgeListPanel");
const edgeListPre = document.getElementById("edgeListPre");
const NS = "http://www.w3.org/2000/svg"; const NS = "http://www.w3.org/2000/svg";
let lastTopology = null; let lastTopology = null;
let lastHighlight = null; let lastHighlight = null;
let lastDiff = null; let lastDiff = null;
/** @type {{ip:string,ping:string,name:string,snmp:string}[]} */
let deviceRowsCache = [];
let deviceSort = { key: "ip", dir: 1 };
let lastGraphViewBox = "0 0 1200 620";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -176,9 +195,73 @@
return null; return null;
} }
function ipv4ToNum(s) {
const p = String(s).split(".");
if (p.length !== 4) return null;
const a = p.map((x) => parseInt(x, 10));
if (a.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null;
return (((a[0] << 24) | (a[1] << 16) | (a[2] << 8) | a[3]) >>> 0);
}
function compareRows(a, b, key) {
let cmp = 0;
if (key === "ip") {
const na = ipv4ToNum(a.ip), nb = ipv4ToNum(b.ip);
if (na != null && nb != null) cmp = na - nb;
else cmp = String(a.ip).localeCompare(String(b.ip), "ru");
} else if (key === "ping") {
cmp = String(a.ping).localeCompare(String(b.ping), "ru");
} else if (key === "name") {
cmp = String(a.name).localeCompare(String(b.name), "ru", { sensitivity: "base" });
} else if (key === "snmp") {
cmp = String(a.snmp).localeCompare(String(b.snmp), "ru", { sensitivity: "base" });
}
return cmp;
}
function updateDeviceSortIndicators() {
deviceTableHead.querySelectorAll("[data-sort]").forEach((th) => {
const key = th.getAttribute("data-sort");
const ind = th.querySelector(".sort-ind");
if (!ind) return;
if (key === deviceSort.key) ind.textContent = deviceSort.dir > 0 ? "▲" : "▼";
else ind.textContent = "";
});
}
function renderDeviceTableBody() {
updateDeviceSortIndicators();
if (deviceRowsCache.length === 0) {
deviceTableBody.innerHTML = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Нет хостов с ping «вверх» для этого scan (или скан ещё не дошёл до них).</td></tr>';
return;
}
const sorted = [...deviceRowsCache].sort((a, b) => deviceSort.dir * compareRows(a, b, deviceSort.key));
deviceTableBody.innerHTML = sorted
.map(
(r) =>
`<tr><td>${escapeHtml(r.ip)}</td><td>${escapeHtml(r.ping)}</td><td>${escapeHtml(r.name)}</td><td>${escapeHtml(r.snmp)}</td></tr>`
)
.join("");
}
deviceTableHead.addEventListener("click", (ev) => {
const th = ev.target.closest("[data-sort]");
if (!th || !deviceTableHead.contains(th)) return;
const key = th.getAttribute("data-sort");
if (!key) return;
if (deviceSort.key === key) deviceSort.dir *= -1;
else {
deviceSort.key = key;
deviceSort.dir = 1;
}
renderDeviceTableBody();
});
async function loadDeviceTable(scanId) { async function loadDeviceTable(scanId) {
if (!scanId) { if (!scanId) {
deviceRowsCache = [];
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();
return; return;
} }
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>';
@@ -195,21 +278,19 @@
for (const r of sData.items || []) { for (const r of sData.items || []) {
snmpByIp.set(r.ip, r); snmpByIp.set(r.ip, r);
} }
const rows = []; deviceRowsCache = [];
for (const h of hData.items || []) { for (const h of hData.items || []) {
if (!h.is_up) continue; if (!h.is_up) continue;
const s = snmpByIp.get(h.ip); const s = snmpByIp.get(h.ip);
const name = s && s.success ? (s.sys_name || "(пусто)") : "—"; const name = s && s.success ? s.sys_name || "(пусто)" : "—";
const snmpCell = s ? (s.success ? "OK" : (s.error || "нет ответа")) : "не опрашивался"; const snmpCell = s ? (s.success ? "OK" : s.error || "нет ответа") : "не опрашивался";
rows.push(`<tr><td>${escapeHtml(h.ip)}</td><td>да</td><td>${escapeHtml(name)}</td><td>${escapeHtml(snmpCell)}</td></tr>`); deviceRowsCache.push({ ip: h.ip, ping: "да", name, snmp: snmpCell });
} }
if (rows.length === 0) { renderDeviceTableBody();
deviceTableBody.innerHTML = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Нет хостов с ping «вверх» для этого scan (или скан ещё не дошёл до них).</td></tr>';
return;
}
deviceTableBody.innerHTML = rows.join("");
} catch (e) { } catch (e) {
deviceRowsCache = [];
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();
} }
} }
@@ -237,52 +318,205 @@
while (svg.firstChild) svg.removeChild(svg.firstChild); while (svg.firstChild) svg.removeChild(svg.firstChild);
} }
/** Пружинная раскладка: узлы с связями разъезжаются, связанные тянутся ближе. */
function layoutSpring(nodes, edges, w, h) {
const centerX = w / 2;
const centerY = h / 2;
const n = nodes.length;
if (n === 0) return new Map();
const state = new Map();
nodes.forEach((node, i) => {
const a = (2 * Math.PI * i) / Math.max(n, 1);
const r0 = Math.min(w, h) * 0.22;
state.set(node.ip, { x: centerX + r0 * Math.cos(a), y: centerY + r0 * Math.sin(a), vx: 0, vy: 0 });
});
const visibleIPs = new Set(nodes.map((nd) => nd.ip));
const realEdges = (edges || []).filter((e) => visibleIPs.has(e.source_ip) && e.target_ip && visibleIPs.has(e.target_ip));
const ideal = Math.max(100, Math.min(200, Math.sqrt((w * h) / Math.max(n, 1))));
const iters = Math.min(420, 100 + n * 18);
for (let iter = 0; iter < iters; iter++) {
for (const s of state.values()) {
s.vx = 0;
s.vy = 0;
}
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const pi = state.get(nodes[i].ip);
const pj = state.get(nodes[j].ip);
let dx = pj.x - pi.x;
let dy = pj.y - pi.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
const rep = (ideal * ideal) / dist;
const fx = (dx / dist) * rep * 0.18;
const fy = (dy / dist) * rep * 0.18;
pi.vx -= fx;
pi.vy -= fy;
pj.vx += fx;
pj.vy += fy;
}
}
for (const e of realEdges) {
const ps = state.get(e.source_ip);
const pt = state.get(e.target_ip);
if (!ps || !pt) continue;
let dx = pt.x - ps.x;
let dy = pt.y - ps.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
const f = (dist - ideal) * 0.06;
dx /= dist;
dy /= dist;
ps.vx += dx * f;
ps.vy += dy * f;
pt.vx -= dx * f;
pt.vy -= dy * f;
}
const pull = realEdges.length === 0 ? 0.02 : 0.014;
for (const node of nodes) {
const p = state.get(node.ip);
p.vx += (centerX - p.x) * pull;
p.vy += (centerY - p.y) * pull;
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.74;
p.vy *= 0.74;
p.x = Math.max(32, Math.min(w - 32, p.x));
p.y = Math.max(32, Math.min(h - 32, p.y));
}
}
const pos = new Map();
for (const node of nodes) {
const p = state.get(node.ip);
pos.set(node.ip, { x: p.x, y: p.y });
}
return pos;
}
function shortenSegment(x1, y1, x2, y2, cutStart, cutEnd) {
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const ux = dx / len;
const uy = dy / len;
return {
x1: x1 + ux * cutStart,
y1: y1 + uy * cutStart,
x2: x2 - ux * cutEnd,
y2: y2 - uy * cutEnd,
};
}
function buildEdgeListText(edges, visibleIPs) {
const lines = [];
let idx = 0;
for (const e of edges || []) {
if (!visibleIPs.has(e.source_ip)) continue;
const src = `${e.source_ip}${e.source_port ? ` лок.порт: ${e.source_port}` : ""}`;
const parts = [];
if (e.target_ip) parts.push(e.target_ip);
if (e.target_name) parts.push(`«${e.target_name}»`);
if (e.target_port) parts.push(`уд.порт: ${e.target_port}`);
const rhs = parts.length ? parts.join(" ") : e.target_name || "?";
lines.push(`${++idx}. ${src}${rhs}`);
}
return lines.join("\n");
}
function drawTopology(data, highlight = null) { function drawTopology(data, highlight = null) {
clearSVG(); clearSVG();
edgeListPanel.style.display = "none";
edgeListPre.textContent = "";
let nodes = data.nodes || []; let nodes = data.nodes || [];
const edges = data.edges || []; const edges = data.edges || [];
const filter = nodeFilter.value || "all"; const filter = nodeFilter.value || "all";
if (highlight && (filter === "new" || filter === "missing")) { if (highlight && (filter === "new" || filter === "missing")) {
nodes = nodes.filter(n => { nodes = nodes.filter((n) => {
const isNew = highlight.newHosts && highlight.newHosts.has(n.ip); const isNew = highlight.newHosts && highlight.newHosts.has(n.ip);
const isMissing = highlight.missingHosts && highlight.missingHosts.has(n.ip); const isMissing = highlight.missingHosts && highlight.missingHosts.has(n.ip);
return filter === "new" ? isNew : isMissing; return filter === "new" ? isNew : isMissing;
}); });
} }
const visibleIPs = new Set(nodes.map(n => n.ip)); if (nodes.length === 0) {
const byIP = new Map(); lastGraphViewBox = "0 0 1200 620";
nodes.forEach(n => byIP.set(n.ip, n)); svg.setAttribute("viewBox", lastGraphViewBox);
return;
}
const centerX = 600; const W = 1160;
const centerY = 310; const H = 580;
const radius = Math.max(160, Math.min(260, 60 + nodes.length * 8)); const visibleIPs = new Set(nodes.map((n) => n.ip));
const pos = new Map(); const pos = layoutSpring(nodes, edges, W, H);
nodes.forEach((n, i) => {
const a = (Math.PI * 2 * i) / Math.max(nodes.length, 1);
pos.set(n.ip, {
x: centerX + radius * Math.cos(a),
y: centerY + radius * Math.sin(a),
});
});
edges.forEach(e => { const defs = document.createElementNS(NS, "defs");
const mk = document.createElementNS(NS, "marker");
mk.setAttribute("id", "arrowhead");
mk.setAttribute("markerWidth", "10");
mk.setAttribute("markerHeight", "10");
mk.setAttribute("refX", "9");
mk.setAttribute("refY", "3");
mk.setAttribute("orient", "auto");
mk.setAttribute("markerUnits", "strokeWidth");
const arrPath = document.createElementNS(NS, "path");
arrPath.setAttribute("d", "M0,0 L0,6 L9,3 z");
arrPath.setAttribute("fill", "#475569");
mk.appendChild(arrPath);
defs.appendChild(mk);
svg.appendChild(defs);
const edgeLabels = (edges || []).length <= 55;
const nodeR = 18;
const cutS = nodeR + 2;
const cutT = nodeR + 8;
(edges || []).forEach((e, ei) => {
const s = pos.get(e.source_ip); const s = pos.get(e.source_ip);
if (!s) return; if (!s || !visibleIPs.has(e.source_ip)) return;
if (!visibleIPs.has(e.source_ip)) return;
if (e.target_ip && !visibleIPs.has(e.target_ip)) return; if (e.target_ip && !visibleIPs.has(e.target_ip)) return;
const t = pos.get(e.target_ip) || { x: s.x + 30, y: s.y + 30 }; const t = e.target_ip ? pos.get(e.target_ip) : { x: s.x + 55, y: s.y + 35 };
const seg = shortenSegment(s.x, s.y, t.x, t.y, cutS, e.target_ip ? cutT : 4);
if (seg.x2 - seg.x1 === 0 && seg.y2 - seg.y1 === 0) return;
const line = document.createElementNS(NS, "line"); const line = document.createElementNS(NS, "line");
line.setAttribute("x1", String(s.x)); line.setAttribute("x1", String(seg.x1));
line.setAttribute("y1", String(s.y)); line.setAttribute("y1", String(seg.y1));
line.setAttribute("x2", String(t.x)); line.setAttribute("x2", String(seg.x2));
line.setAttribute("y2", String(t.y)); line.setAttribute("y2", String(seg.y2));
line.setAttribute("stroke", "#94a3b8"); const hue = (ei * 47) % 360;
line.setAttribute("stroke-width", "1.5"); line.setAttribute("stroke", `hsl(${hue} 35% 42%)`);
line.setAttribute("stroke-width", "2");
line.setAttribute("marker-end", "url(#arrowhead)");
line.setAttribute("opacity", "0.92");
svg.appendChild(line); svg.appendChild(line);
if (edgeLabels) {
const parts = [];
if (e.source_port) parts.push(String(e.source_port));
if (e.target_port) parts.push(String(e.target_port));
let cap = parts.join(" → ");
if (e.target_name && !cap) cap = e.target_name;
if (e.target_name && cap.length < 40) cap = `${cap} (${e.target_name})`;
if (cap.length > 42) cap = cap.slice(0, 40) + "…";
if (cap) {
const mx = (seg.x1 + seg.x2) / 2;
const my = (seg.y1 + seg.y2) / 2;
const et = document.createElementNS(NS, "text");
et.setAttribute("x", String(mx));
et.setAttribute("y", String(my - 4));
et.setAttribute("font-size", "10");
et.setAttribute("fill", "#334155");
et.setAttribute("text-anchor", "middle");
et.setAttribute("paint-order", "stroke");
et.setAttribute("stroke", "#f8fafc");
et.setAttribute("stroke-width", "3");
et.textContent = cap;
svg.appendChild(et);
}
}
}); });
nodes.forEach(n => { nodes.forEach((n) => {
const p = pos.get(n.ip); const p = pos.get(n.ip);
if (!p) return; if (!p) return;
const isNew = highlight && highlight.newHosts && highlight.newHosts.has(n.ip); const isNew = highlight && highlight.newHosts && highlight.newHosts.has(n.ip);
@@ -291,21 +525,49 @@
const circle = document.createElementNS(NS, "circle"); const circle = document.createElementNS(NS, "circle");
circle.setAttribute("cx", String(p.x)); circle.setAttribute("cx", String(p.x));
circle.setAttribute("cy", String(p.y)); circle.setAttribute("cy", String(p.y));
circle.setAttribute("r", "16"); circle.setAttribute("r", String(nodeR));
circle.setAttribute("fill", isNew ? "#16a34a" : (isMissing ? "#dc2626" : "#2563eb")); circle.setAttribute("fill", isNew ? "#16a34a" : isMissing ? "#dc2626" : "#2563eb");
circle.setAttribute("opacity", "0.9"); circle.setAttribute("opacity", "0.95");
circle.setAttribute("stroke", "#1e293b");
circle.setAttribute("stroke-width", "1.5");
svg.appendChild(circle); svg.appendChild(circle);
const txt = document.createElementNS(NS, "text"); const txt = document.createElementNS(NS, "text");
txt.setAttribute("x", String(p.x + 22)); const lx = p.x + nodeR + 6;
txt.setAttribute("y", String(p.y + 5)); const lines = n.name ? [n.name, `(${n.ip})`] : [n.ip];
lines.forEach((line, li) => {
const tspan = document.createElementNS(NS, "tspan");
tspan.setAttribute("x", String(lx));
tspan.setAttribute("dy", li === 0 ? "0.35em" : "1.2em");
tspan.textContent = line;
txt.appendChild(tspan);
});
txt.setAttribute("font-size", "12"); txt.setAttribute("font-size", "12");
txt.setAttribute("fill", "#111827"); txt.setAttribute("fill", "#0f172a");
txt.textContent = n.name ? `${n.name} (${n.ip})` : n.ip;
if (isNew) txt.textContent += " [+]";
if (isMissing) txt.textContent += " [-]";
svg.appendChild(txt); svg.appendChild(txt);
}); });
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const p of pos.values()) {
minX = Math.min(minX, p.x - nodeR - 6);
maxX = Math.max(maxX, p.x + nodeR + 220);
minY = Math.min(minY, p.y - nodeR - 6);
maxY = Math.max(maxY, p.y + nodeR + 36);
}
const pad = 24;
const vbW = maxX - minX + pad * 2;
const vbH = maxY - minY + pad * 2;
lastGraphViewBox = `${minX - pad} ${minY - pad} ${vbW} ${vbH}`;
svg.setAttribute("viewBox", lastGraphViewBox);
const listTxt = buildEdgeListText(edges, visibleIPs);
if (listTxt) {
edgeListPre.textContent = listTxt;
edgeListPanel.style.display = "";
}
} }
function fillSelect(selectEl, scans, placeholder) { function fillSelect(selectEl, scans, placeholder) {
@@ -541,7 +803,7 @@
setStatus("Diff JSON экспортирован"); setStatus("Diff JSON экспортирован");
}); });
fitBtn.addEventListener("click", () => { fitBtn.addEventListener("click", () => {
svg.setAttribute("viewBox", "0 0 1200 620"); svg.setAttribute("viewBox", lastGraphViewBox || "0 0 1200 620");
}); });
nodeFilter.addEventListener("change", () => { nodeFilter.addEventListener("change", () => {
if (lastTopology) { if (lastTopology) {
@@ -549,6 +811,7 @@
} }
}); });
setDiff("Diff будет показан здесь..."); setDiff("Diff будет показан здесь...");
updateDeviceSortIndicators();
loadScanOptions(); loadScanOptions();
</script> </script>
</body> </body>