6d75de1e2e
Made-with: Cursor
982 lines
45 KiB
HTML
982 lines
45 KiB
HTML
<!doctype html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>NetTopo UI</title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 16px; color: #1f2937; }
|
|
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
|
|
input, select, button { padding: 8px 10px; font-size: 14px; }
|
|
#status { margin: 8px 0; color: #1e40af; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 10px 12px; font-size: 14px; line-height: 1.45; }
|
|
#canvas { border: 1px solid #d1d5db; border-radius: 8px; width: 100%; height: 620px; }
|
|
.hint { color: #6b7280; font-size: 13px; margin-bottom: 8px; }
|
|
.pipeline { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; margin-bottom: 14px; font-size: 13px; line-height: 1.5; color: #334155; }
|
|
.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; }
|
|
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>
|
|
</head>
|
|
<body>
|
|
<h2>NetTopo: просмотр топологии</h2>
|
|
<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>
|
|
<div class="row">
|
|
<input id="cidrInput" placeholder="CIDR, например 192.168.1.0/24" style="min-width: 320px;" />
|
|
<button id="createScanBtn">Создать scan и ждать</button>
|
|
<button id="watchScanBtn">Ждать завершения (текущий scan_id)</button>
|
|
</div>
|
|
<div class="row">
|
|
<input id="scanId" placeholder="scan_id" style="min-width: 380px;" />
|
|
<select id="scanSelect" style="min-width: 360px;"></select>
|
|
<button id="loadBtn">Загрузить</button>
|
|
<button id="latestBtn">Последний scan</button>
|
|
<button id="exportTopologyBtn">Экспорт topology JSON</button>
|
|
<button id="fitBtn">По центру</button>
|
|
</div>
|
|
<div class="row">
|
|
<input id="fromScanId" placeholder="from scan_id" style="min-width: 280px;" />
|
|
<input id="toScanId" placeholder="to scan_id" style="min-width: 280px;" />
|
|
<select id="fromScanSelect" style="min-width: 240px;"></select>
|
|
<select id="toScanSelect" style="min-width: 240px;"></select>
|
|
<button id="diffBtn">Сравнить</button>
|
|
<button id="exportDiffBtn">Экспорт diff JSON</button>
|
|
</div>
|
|
<div id="status">Ожидание действия. Строка статуса показывает текущую операцию и прогресс скана.</div>
|
|
|
|
<div class="panel">
|
|
<div class="row" style="margin-bottom: 6px;">
|
|
<strong>Найденные устройства</strong>
|
|
<span class="hint" style="margin:0;">отвечают на ping; колонка LLDP: при отсутствии соседей в MIB — пометка «No LLDP data» (часто для коммутаторов без LLDP или без успешного SNMP). Клик по строке — связи ниже.</span>
|
|
<button type="button" id="refreshDevicesBtn">Обновить список</button>
|
|
</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 id="deviceTableHead">
|
|
<tr style="background:#f1f5f9;">
|
|
<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>
|
|
<th class="sortable-th" data-sort="lldp" style="text-align:left;">LLDP <span class="sort-ind"></span></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="deviceTableBody">
|
|
<tr><td colspan="5" style="padding:10px;color:#64748b;">Укажите scan_id или создайте scan.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</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;">
|
|
Легенда:
|
|
<span style="display:inline-block;margin-left:8px;color:#2563eb;">● синий - обычный узел</span>
|
|
<span style="display:inline-block;margin-left:8px;color:#16a34a;">● зеленый - новый</span>
|
|
<span style="display:inline-block;margin-left:8px;color:#dc2626;">● красный - пропавший</span>
|
|
<span style="display:inline-block;margin-left:12px;">Фильтр:</span>
|
|
<select id="nodeFilter">
|
|
<option value="all">все</option>
|
|
<option value="new">только новые</option>
|
|
<option value="missing">только пропавшие</option>
|
|
</select>
|
|
</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");
|
|
const cidrInput = document.getElementById("cidrInput");
|
|
const scanSelect = document.getElementById("scanSelect");
|
|
const fromScanIdInput = document.getElementById("fromScanId");
|
|
const toScanIdInput = document.getElementById("toScanId");
|
|
const fromScanSelect = document.getElementById("fromScanSelect");
|
|
const toScanSelect = document.getElementById("toScanSelect");
|
|
const loadBtn = document.getElementById("loadBtn");
|
|
const createScanBtn = document.getElementById("createScanBtn");
|
|
const watchScanBtn = document.getElementById("watchScanBtn");
|
|
const latestBtn = document.getElementById("latestBtn");
|
|
const diffBtn = document.getElementById("diffBtn");
|
|
const exportTopologyBtn = document.getElementById("exportTopologyBtn");
|
|
const exportDiffBtn = document.getElementById("exportDiffBtn");
|
|
const fitBtn = document.getElementById("fitBtn");
|
|
const nodeFilter = document.getElementById("nodeFilter");
|
|
const statusEl = document.getElementById("status");
|
|
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,lldp:string,lldpCount:number}[]} */
|
|
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));
|
|
|
|
function setStatus(text) { statusEl.textContent = text; }
|
|
|
|
async function fetchScanJob(id) {
|
|
const res = await fetch(`/api/scans/${encodeURIComponent(id)}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
|
return res.json();
|
|
}
|
|
|
|
/** Человекочитаемое описание задачи для строки статуса */
|
|
function describeScanProgress(job) {
|
|
const id = job.id || "";
|
|
const st = job.status || "";
|
|
const p = job.progress ?? 0;
|
|
const t = job.stats?.hosts_total ?? 0;
|
|
const up = job.stats?.hosts_up ?? 0;
|
|
if (st === "queued") {
|
|
return `Задача ${id}: в очереди, подготовка списка адресов…`;
|
|
}
|
|
if (st === "running") {
|
|
return `Скан ${id}: выполняется обход сети — обработано ~${p}% адресов из ${t}; отвечают на ping (уже проверены): ${up}. Для каждого отвечающего: порты → SNMP → LLDP.`;
|
|
}
|
|
if (st === "done") {
|
|
return `Скан ${id}: завершён. Всего адресов в задании: ${t}, ответили на ping: ${up}.`;
|
|
}
|
|
if (st === "failed") {
|
|
return `Скан ${id}: помечен как failed (смотрите journalctl на сервере).`;
|
|
}
|
|
return `Скан ${id}: статус «${st}», прогресс ${p}%.`;
|
|
}
|
|
|
|
/**
|
|
* Опрашивает /api/scans/{id} до done/failed или таймаута.
|
|
* @param {{ intervalMs?: number, maxWaitMs?: number, onDone?: function }} opts
|
|
*/
|
|
async function pollScanUntilDone(scanId, opts = {}) {
|
|
const intervalMs = opts.intervalMs ?? 1000;
|
|
const maxWaitMs = opts.maxWaitMs ?? 20 * 60 * 1000;
|
|
const started = Date.now();
|
|
while (Date.now() - started < maxWaitMs) {
|
|
let job;
|
|
try {
|
|
job = await fetchScanJob(scanId);
|
|
} catch (e) {
|
|
setStatus(`Ошибка опроса скана: ${e.message}`);
|
|
return null;
|
|
}
|
|
setStatus(describeScanProgress(job));
|
|
if (job.status === "done" || job.status === "failed" || job.status === "canceled") {
|
|
if (opts.onDone) {
|
|
try {
|
|
await opts.onDone(job);
|
|
} catch (e) {
|
|
setStatus((statusEl.textContent || "") + ` | Ошибка в onDone: ${e.message}`);
|
|
}
|
|
}
|
|
return job;
|
|
}
|
|
await sleep(intervalMs);
|
|
}
|
|
setStatus(`Таймаут ожидания скана ${scanId} (${Math.round(maxWaitMs / 60000)} мин).`);
|
|
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" });
|
|
} else if (key === "lldp") {
|
|
cmp = (a.lldpCount || 0) - (b.lldpCount || 0);
|
|
}
|
|
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="5" 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) => {
|
|
const dip = escapeHtml(r.ip);
|
|
const noLldp = (r.lldpCount || 0) === 0;
|
|
const lldpStyle = noLldp ? ' style="color:#9a3412;font-weight:500;"' : "";
|
|
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><td${lldpStyle}>${escapeHtml(r.lldp)}</td></tr>`;
|
|
})
|
|
.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) => {
|
|
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 = [];
|
|
clearDeviceDetailPanel();
|
|
deviceTableBody.innerHTML = '<tr><td colspan="5" style="padding:10px;color:#64748b;">Нет scan_id.</td></tr>';
|
|
updateDeviceSortIndicators();
|
|
return;
|
|
}
|
|
clearDeviceDetailPanel();
|
|
deviceTableBody.innerHTML = '<tr><td colspan="5" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
|
|
try {
|
|
const [hRes, sRes, lRes] = await Promise.all([
|
|
fetch(`/api/scans/${encodeURIComponent(scanId)}/hosts?limit=5000`),
|
|
fetch(`/api/scans/${encodeURIComponent(scanId)}/snmp?limit=5000`),
|
|
fetch(`/api/scans/${encodeURIComponent(scanId)}/lldp?limit=10000`),
|
|
]);
|
|
if (!hRes.ok) throw new Error(await hRes.text());
|
|
if (!sRes.ok) throw new Error(await sRes.text());
|
|
if (!lRes.ok) throw new Error((await lRes.text()) || "LLDP");
|
|
const hData = await hRes.json();
|
|
const sData = await sRes.json();
|
|
const lData = await lRes.json();
|
|
const snmpByIp = new Map();
|
|
for (const r of sData.items || []) {
|
|
snmpByIp.set(r.ip, r);
|
|
}
|
|
const lldpCountByIp = new Map();
|
|
for (const row of lData.items || []) {
|
|
const ipk = row.ip;
|
|
if (!ipk) continue;
|
|
lldpCountByIp.set(ipk, (lldpCountByIp.get(ipk) || 0) + 1);
|
|
}
|
|
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 || "нет ответа") : "не опрашивался";
|
|
const lldpCount = lldpCountByIp.get(h.ip) || 0;
|
|
const lldpCell = lldpCount === 0 ? "No LLDP data" : `${lldpCount} записей`;
|
|
deviceRowsCache.push({ ip: h.ip, ping: "да", name, snmp: snmpCell, lldp: lldpCell, lldpCount });
|
|
}
|
|
renderDeviceTableBody();
|
|
} catch (e) {
|
|
deviceRowsCache = [];
|
|
clearDeviceDetailPanel();
|
|
deviceTableBody.innerHTML = `<tr><td colspan="5" style="padding:10px;color:#b91c1c;">Ошибка: ${escapeHtml(e.message)}</td></tr>`;
|
|
updateDeviceSortIndicators();
|
|
}
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
function setDiff(text) { diffBox.textContent = text; }
|
|
function downloadJSON(filename, data) {
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function clearSVG() {
|
|
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) => {
|
|
const isNew = highlight.newHosts && highlight.newHosts.has(n.ip);
|
|
const isMissing = highlight.missingHosts && highlight.missingHosts.has(n.ip);
|
|
return filter === "new" ? isNew : isMissing;
|
|
});
|
|
}
|
|
if (nodes.length === 0) {
|
|
lastGraphViewBox = "0 0 1200 620";
|
|
svg.setAttribute("viewBox", lastGraphViewBox);
|
|
return;
|
|
}
|
|
|
|
const W = 1160;
|
|
const H = 580;
|
|
const visibleIPs = new Set(nodes.map((n) => n.ip));
|
|
const pos = layoutSpring(nodes, edges, W, H);
|
|
|
|
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 || !visibleIPs.has(e.source_ip)) return;
|
|
if (e.target_ip && !visibleIPs.has(e.target_ip)) return;
|
|
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(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) => {
|
|
const p = pos.get(n.ip);
|
|
if (!p) return;
|
|
const isNew = highlight && highlight.newHosts && highlight.newHosts.has(n.ip);
|
|
const isMissing = highlight && highlight.missingHosts && highlight.missingHosts.has(n.ip);
|
|
|
|
const circle = document.createElementNS(NS, "circle");
|
|
circle.setAttribute("cx", String(p.x));
|
|
circle.setAttribute("cy", String(p.y));
|
|
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");
|
|
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", "#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) {
|
|
selectEl.innerHTML = "";
|
|
const empty = document.createElement("option");
|
|
empty.value = "";
|
|
empty.textContent = placeholder;
|
|
selectEl.appendChild(empty);
|
|
scans.forEach(s => {
|
|
const opt = document.createElement("option");
|
|
opt.value = s.id;
|
|
opt.textContent = `${s.id} (${s.status})`;
|
|
selectEl.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
async function loadScanOptions() {
|
|
try {
|
|
const res = await fetch("/api/scans?limit=20");
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
const scans = data.items || [];
|
|
fillSelect(scanSelect, scans, "Выбрать scan");
|
|
fillSelect(fromScanSelect, scans, "from scan");
|
|
fillSelect(toScanSelect, scans, "to scan");
|
|
if (scans[0] && scans[0].id) {
|
|
scanIdInput.value = scans[0].id;
|
|
toScanIdInput.value = scans[0].id;
|
|
}
|
|
if (scans[1] && scans[1].id) {
|
|
fromScanIdInput.value = scans[1].id;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
async function loadTopology() {
|
|
const id = scanIdInput.value.trim();
|
|
if (!id) {
|
|
setStatus("Введите scan_id");
|
|
return;
|
|
}
|
|
setStatus("Загружаю topology...");
|
|
try {
|
|
const res = await fetch(`/api/scans/${encodeURIComponent(id)}/topology?limit=5000`);
|
|
if (!res.ok) {
|
|
const t = await res.text();
|
|
setStatus(`Ошибка ${res.status}: ${t}`);
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
lastTopology = data;
|
|
lastHighlight = null;
|
|
drawTopology(data, null);
|
|
setStatus(`Готово. Узлов: ${data.nodes?.length || 0}, связей: ${data.edges?.length || 0}`);
|
|
} catch (e) {
|
|
setStatus(`Ошибка загрузки: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
async function createScan() {
|
|
const cidr = cidrInput.value.trim();
|
|
if (!cidr) {
|
|
setStatus("Введите CIDR (подсеть для сканирования).");
|
|
return;
|
|
}
|
|
setStatus("Отправка запроса на сервер: создание задачи scan…");
|
|
try {
|
|
const payload = {
|
|
name: `manual-${new Date().toISOString()}`,
|
|
cidrs: [cidr],
|
|
exclude_ips: [],
|
|
options: {
|
|
ping_timeout_ms: 700,
|
|
ping_retries: 1,
|
|
max_parallel_hosts: 128,
|
|
port_scan_enabled: true,
|
|
ports: [22, 80, 443, 161]
|
|
}
|
|
};
|
|
const res = await fetch("/api/scans", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (!res.ok) {
|
|
const t = await res.text();
|
|
setStatus(`Ошибка создания scan: ${res.status} ${t}`);
|
|
return;
|
|
}
|
|
const d = await res.json();
|
|
const sid = d.scan_id;
|
|
scanIdInput.value = sid;
|
|
toScanIdInput.value = sid;
|
|
setStatus(`Задача ${sid} создана (статус: ${d.status || "queued"}). Фоновый воркер запустил полный цикл по сети — жду завершения…`);
|
|
await loadScanOptions();
|
|
|
|
await pollScanUntilDone(sid, {
|
|
onDone: async (job) => {
|
|
await loadScanOptions();
|
|
if (job.status === "done") {
|
|
await loadDeviceTable(sid);
|
|
await loadTopology();
|
|
} else if (job.status === "failed") {
|
|
await loadDeviceTable(sid);
|
|
setStatus(describeScanProgress(job) + " Частичные результаты (если есть) в таблице.");
|
|
}
|
|
},
|
|
});
|
|
} catch (e) {
|
|
setStatus(`Ошибка создания scan: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
async function watchScan() {
|
|
const id = scanIdInput.value.trim();
|
|
if (!id) {
|
|
setStatus("Введите scan_id или выберите из списка.");
|
|
return;
|
|
}
|
|
setStatus(`Подключаюсь к задаче ${id}, буду опрашивать статус…`);
|
|
await pollScanUntilDone(id, {
|
|
intervalMs: 1500,
|
|
onDone: async (job) => {
|
|
await loadScanOptions();
|
|
setStatus(describeScanProgress(job));
|
|
if (job.status === "done") {
|
|
await loadDeviceTable(id);
|
|
await loadTopology();
|
|
} else if (job.status === "failed") {
|
|
await loadDeviceTable(id);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
async function loadLatestScan() {
|
|
setStatus("Ищу последний scan...");
|
|
try {
|
|
const res = await fetch("/api/scans?limit=1");
|
|
if (!res.ok) {
|
|
setStatus(`Ошибка ${res.status} при получении scan list`);
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
const latest = data.items && data.items[0];
|
|
if (!latest || !latest.id) {
|
|
setStatus("Сканы не найдены");
|
|
return;
|
|
}
|
|
scanIdInput.value = latest.id;
|
|
toScanIdInput.value = latest.id;
|
|
setStatus(`Найден последний scan: ${latest.id}`);
|
|
await loadDeviceTable(latest.id);
|
|
await loadTopology();
|
|
} catch (e) {
|
|
setStatus(`Ошибка загрузки последнего scan: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
async function loadDiff() {
|
|
const fromId = fromScanIdInput.value.trim();
|
|
const toId = toScanIdInput.value.trim();
|
|
if (!fromId || !toId) {
|
|
setStatus("Введите from и to scan_id");
|
|
return;
|
|
}
|
|
setStatus("Сравниваю сканы...");
|
|
try {
|
|
const url = `/api/scans/diff?from=${encodeURIComponent(fromId)}&to=${encodeURIComponent(toId)}`;
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
const t = await res.text();
|
|
setStatus(`Ошибка ${res.status}: ${t}`);
|
|
return;
|
|
}
|
|
const d = await res.json();
|
|
lastDiff = d;
|
|
setDiff(JSON.stringify(d, null, 2));
|
|
const topoRes = await fetch(`/api/scans/${encodeURIComponent(toId)}/topology?limit=5000`);
|
|
if (topoRes.ok) {
|
|
const topo = await topoRes.json();
|
|
const highlight = {
|
|
newHosts: new Set(d.new_hosts || []),
|
|
missingHosts: new Set(d.missing_hosts || []),
|
|
};
|
|
lastTopology = topo;
|
|
lastHighlight = highlight;
|
|
drawTopology(topo, highlight);
|
|
}
|
|
setStatus(
|
|
`Diff готов: +hosts ${d.new_hosts?.length || 0}, -hosts ${d.missing_hosts?.length || 0}, +links ${d.new_links?.length || 0}, -links ${d.missing_links?.length || 0}`
|
|
);
|
|
} catch (e) {
|
|
setStatus(`Ошибка diff: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
loadBtn.addEventListener("click", async () => {
|
|
await loadTopology();
|
|
await loadDeviceTable(scanIdInput.value.trim());
|
|
});
|
|
refreshDevicesBtn.addEventListener("click", () => loadDeviceTable(scanIdInput.value.trim()));
|
|
createScanBtn.addEventListener("click", createScan);
|
|
watchScanBtn.addEventListener("click", watchScan);
|
|
scanSelect.addEventListener("change", () => {
|
|
if (scanSelect.value) scanIdInput.value = scanSelect.value;
|
|
});
|
|
fromScanSelect.addEventListener("change", () => {
|
|
if (fromScanSelect.value) fromScanIdInput.value = fromScanSelect.value;
|
|
});
|
|
toScanSelect.addEventListener("change", () => {
|
|
if (toScanSelect.value) toScanIdInput.value = toScanSelect.value;
|
|
});
|
|
latestBtn.addEventListener("click", loadLatestScan);
|
|
diffBtn.addEventListener("click", loadDiff);
|
|
exportTopologyBtn.addEventListener("click", () => {
|
|
const scanId = scanIdInput.value.trim() || "unknown";
|
|
if (!lastTopology) {
|
|
setStatus("Сначала загрузите topology");
|
|
return;
|
|
}
|
|
downloadJSON(`topology_${scanId}.json`, lastTopology);
|
|
setStatus("Topology JSON экспортирован");
|
|
});
|
|
exportDiffBtn.addEventListener("click", () => {
|
|
const fromId = fromScanIdInput.value.trim() || "from";
|
|
const toId = toScanIdInput.value.trim() || "to";
|
|
if (!lastDiff) {
|
|
setStatus("Сначала выполните diff");
|
|
return;
|
|
}
|
|
downloadJSON(`diff_${fromId}_to_${toId}.json`, lastDiff);
|
|
setStatus("Diff JSON экспортирован");
|
|
});
|
|
fitBtn.addEventListener("click", () => {
|
|
svg.setAttribute("viewBox", lastGraphViewBox || "0 0 1200 620");
|
|
});
|
|
nodeFilter.addEventListener("change", () => {
|
|
if (lastTopology) {
|
|
drawTopology(lastTopology, lastHighlight);
|
|
}
|
|
});
|
|
setDiff("Diff будет показан здесь...");
|
|
updateDeviceSortIndicators();
|
|
loadScanOptions();
|
|
</script>
|
|
</body>
|
|
</html>
|