UI: sortable device table columns, spring graph layout, edge arrows and link list.
Made-with: Cursor
This commit is contained in:
+314
-51
@@ -15,11 +15,17 @@
|
||||
.pipeline strong { color: #0f172a; }
|
||||
.panel { margin: 14px 0; }
|
||||
#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>
|
||||
</head>
|
||||
<body>
|
||||
<h2>NetTopo: просмотр топологии</h2>
|
||||
<div class="hint">После скана нажмите «Загрузить», чтобы нарисовать граф по данным LLDP/SNMP.</div>
|
||||
<div class="hint">После скана нажмите «Загрузить»: граф раскладывается пружинной моделью по LLDP, стрелки — от локального порта к соседу; полный список связей — блок под графом. Таблица устройств: сортировка по заголовкам.</div>
|
||||
<div class="pipeline">
|
||||
<strong>Один scan = один полный проход по сети.</strong> Для каждого IP из CIDR (с исключениями): <strong>ping</strong> → для ответивших — проверка <strong>TCP-портов</strong> → если включён SNMP — опрос <strong>системы (sysName и др.)</strong> и сразу обход <strong>LLDP-MIB</strong> (соседи по портам). Отдельной кнопки «только LLDP» нет: соседи попадают в этот же цикл. Кнопка «Создать scan» ставит задачу и сама ждёт завершения, показывая прогресс в строке статуса ниже.
|
||||
</div>
|
||||
@@ -54,12 +60,12 @@
|
||||
</div>
|
||||
<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;">
|
||||
<thead>
|
||||
<thead id="deviceTableHead">
|
||||
<tr style="background:#f1f5f9;">
|
||||
<th style="text-align:left;">IP</th>
|
||||
<th style="text-align:left;">Ping</th>
|
||||
<th style="text-align:left;">Имя (SNMP)</th>
|
||||
<th style="text-align:left;">SNMP</th>
|
||||
<th class="sortable-th" data-sort="ip" style="text-align:left;">IP <span class="sort-ind"></span></th>
|
||||
<th class="sortable-th" data-sort="ping" style="text-align:left;">Ping <span class="sort-ind"></span></th>
|
||||
<th class="sortable-th" data-sort="name" style="text-align:left;">Имя (SNMP) <span class="sort-ind"></span></th>
|
||||
<th class="sortable-th" data-sort="snmp" style="text-align:left;">SNMP <span class="sort-ind"></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="deviceTableBody">
|
||||
@@ -83,6 +89,12 @@
|
||||
</div>
|
||||
<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>
|
||||
<div id="edgeListPanel" style="display:none;">
|
||||
<details open>
|
||||
<summary>Связи LLDP (текстом, по портам)</summary>
|
||||
<pre id="edgeListPre"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const scanIdInput = document.getElementById("scanId");
|
||||
@@ -105,11 +117,18 @@
|
||||
const diffBox = document.getElementById("diffBox");
|
||||
const svg = document.getElementById("canvas");
|
||||
const deviceTableBody = document.getElementById("deviceTableBody");
|
||||
const deviceTableHead = document.getElementById("deviceTableHead");
|
||||
const refreshDevicesBtn = document.getElementById("refreshDevicesBtn");
|
||||
const edgeListPanel = document.getElementById("edgeListPanel");
|
||||
const edgeListPre = document.getElementById("edgeListPre");
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
let lastTopology = null;
|
||||
let lastHighlight = 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));
|
||||
|
||||
@@ -176,9 +195,73 @@
|
||||
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) {
|
||||
if (!scanId) {
|
||||
deviceRowsCache = [];
|
||||
deviceTableBody.innerHTML = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Нет scan_id.</td></tr>';
|
||||
updateDeviceSortIndicators();
|
||||
return;
|
||||
}
|
||||
deviceTableBody.innerHTML = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
|
||||
@@ -195,21 +278,19 @@
|
||||
for (const r of sData.items || []) {
|
||||
snmpByIp.set(r.ip, r);
|
||||
}
|
||||
const rows = [];
|
||||
deviceRowsCache = [];
|
||||
for (const h of hData.items || []) {
|
||||
if (!h.is_up) continue;
|
||||
const s = snmpByIp.get(h.ip);
|
||||
const name = s && s.success ? (s.sys_name || "(пусто)") : "—";
|
||||
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>`);
|
||||
const name = s && s.success ? s.sys_name || "(пусто)" : "—";
|
||||
const snmpCell = s ? (s.success ? "OK" : s.error || "нет ответа") : "не опрашивался";
|
||||
deviceRowsCache.push({ ip: h.ip, ping: "да", name, snmp: snmpCell });
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
deviceTableBody.innerHTML = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Нет хостов с ping «вверх» для этого scan (или скан ещё не дошёл до них).</td></tr>';
|
||||
return;
|
||||
}
|
||||
deviceTableBody.innerHTML = rows.join("");
|
||||
renderDeviceTableBody();
|
||||
} catch (e) {
|
||||
deviceRowsCache = [];
|
||||
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);
|
||||
}
|
||||
|
||||
/** Пружинная раскладка: узлы с связями разъезжаются, связанные тянутся ближе. */
|
||||
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) {
|
||||
clearSVG();
|
||||
edgeListPanel.style.display = "none";
|
||||
edgeListPre.textContent = "";
|
||||
|
||||
let nodes = data.nodes || [];
|
||||
const edges = data.edges || [];
|
||||
const filter = nodeFilter.value || "all";
|
||||
if (highlight && (filter === "new" || filter === "missing")) {
|
||||
nodes = nodes.filter(n => {
|
||||
nodes = nodes.filter((n) => {
|
||||
const isNew = highlight.newHosts && highlight.newHosts.has(n.ip);
|
||||
const isMissing = highlight.missingHosts && highlight.missingHosts.has(n.ip);
|
||||
return filter === "new" ? isNew : isMissing;
|
||||
});
|
||||
}
|
||||
const visibleIPs = new Set(nodes.map(n => n.ip));
|
||||
const byIP = new Map();
|
||||
nodes.forEach(n => byIP.set(n.ip, n));
|
||||
if (nodes.length === 0) {
|
||||
lastGraphViewBox = "0 0 1200 620";
|
||||
svg.setAttribute("viewBox", lastGraphViewBox);
|
||||
return;
|
||||
}
|
||||
|
||||
const centerX = 600;
|
||||
const centerY = 310;
|
||||
const radius = Math.max(160, Math.min(260, 60 + nodes.length * 8));
|
||||
const pos = new Map();
|
||||
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),
|
||||
});
|
||||
});
|
||||
const W = 1160;
|
||||
const H = 580;
|
||||
const visibleIPs = new Set(nodes.map((n) => n.ip));
|
||||
const pos = layoutSpring(nodes, edges, W, H);
|
||||
|
||||
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);
|
||||
if (!s) return;
|
||||
if (!visibleIPs.has(e.source_ip)) return;
|
||||
if (!s || !visibleIPs.has(e.source_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");
|
||||
line.setAttribute("x1", String(s.x));
|
||||
line.setAttribute("y1", String(s.y));
|
||||
line.setAttribute("x2", String(t.x));
|
||||
line.setAttribute("y2", String(t.y));
|
||||
line.setAttribute("stroke", "#94a3b8");
|
||||
line.setAttribute("stroke-width", "1.5");
|
||||
line.setAttribute("x1", String(seg.x1));
|
||||
line.setAttribute("y1", String(seg.y1));
|
||||
line.setAttribute("x2", String(seg.x2));
|
||||
line.setAttribute("y2", String(seg.y2));
|
||||
const hue = (ei * 47) % 360;
|
||||
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);
|
||||
|
||||
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);
|
||||
if (!p) return;
|
||||
const isNew = highlight && highlight.newHosts && highlight.newHosts.has(n.ip);
|
||||
@@ -291,21 +525,49 @@
|
||||
const circle = document.createElementNS(NS, "circle");
|
||||
circle.setAttribute("cx", String(p.x));
|
||||
circle.setAttribute("cy", String(p.y));
|
||||
circle.setAttribute("r", "16");
|
||||
circle.setAttribute("fill", isNew ? "#16a34a" : (isMissing ? "#dc2626" : "#2563eb"));
|
||||
circle.setAttribute("opacity", "0.9");
|
||||
circle.setAttribute("r", String(nodeR));
|
||||
circle.setAttribute("fill", isNew ? "#16a34a" : isMissing ? "#dc2626" : "#2563eb");
|
||||
circle.setAttribute("opacity", "0.95");
|
||||
circle.setAttribute("stroke", "#1e293b");
|
||||
circle.setAttribute("stroke-width", "1.5");
|
||||
svg.appendChild(circle);
|
||||
|
||||
const txt = document.createElementNS(NS, "text");
|
||||
txt.setAttribute("x", String(p.x + 22));
|
||||
txt.setAttribute("y", String(p.y + 5));
|
||||
const lx = p.x + nodeR + 6;
|
||||
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("fill", "#111827");
|
||||
txt.textContent = n.name ? `${n.name} (${n.ip})` : n.ip;
|
||||
if (isNew) txt.textContent += " [+]";
|
||||
if (isMissing) txt.textContent += " [-]";
|
||||
txt.setAttribute("fill", "#0f172a");
|
||||
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) {
|
||||
@@ -541,7 +803,7 @@
|
||||
setStatus("Diff JSON экспортирован");
|
||||
});
|
||||
fitBtn.addEventListener("click", () => {
|
||||
svg.setAttribute("viewBox", "0 0 1200 620");
|
||||
svg.setAttribute("viewBox", lastGraphViewBox || "0 0 1200 620");
|
||||
});
|
||||
nodeFilter.addEventListener("change", () => {
|
||||
if (lastTopology) {
|
||||
@@ -549,6 +811,7 @@
|
||||
}
|
||||
});
|
||||
setDiff("Diff будет показан здесь...");
|
||||
updateDeviceSortIndicators();
|
||||
loadScanOptions();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user