204b4f8768
Made-with: Cursor
556 lines
25 KiB
HTML
556 lines
25 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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h2>NetTopo: просмотр топологии</h2>
|
|
<div class="hint">После скана нажмите «Загрузить», чтобы нарисовать граф по данным LLDP/SNMP.</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; имя — из 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>
|
|
<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>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="deviceTableBody">
|
|
<tr><td colspan="4" style="padding:10px;color:#64748b;">Укажите scan_id или создайте scan.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</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>
|
|
|
|
<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 refreshDevicesBtn = document.getElementById("refreshDevicesBtn");
|
|
const NS = "http://www.w3.org/2000/svg";
|
|
let lastTopology = null;
|
|
let lastHighlight = null;
|
|
let lastDiff = null;
|
|
|
|
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;
|
|
}
|
|
|
|
async function loadDeviceTable(scanId) {
|
|
if (!scanId) {
|
|
deviceTableBody.innerHTML = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Нет scan_id.</td></tr>';
|
|
return;
|
|
}
|
|
deviceTableBody.innerHTML = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
|
|
try {
|
|
const [hRes, sRes] = await Promise.all([
|
|
fetch(`/api/scans/${encodeURIComponent(scanId)}/hosts?limit=5000`),
|
|
fetch(`/api/scans/${encodeURIComponent(scanId)}/snmp?limit=5000`),
|
|
]);
|
|
if (!hRes.ok) throw new Error(await hRes.text());
|
|
if (!sRes.ok) throw new Error(await sRes.text());
|
|
const hData = await hRes.json();
|
|
const sData = await sRes.json();
|
|
const snmpByIp = new Map();
|
|
for (const r of sData.items || []) {
|
|
snmpByIp.set(r.ip, r);
|
|
}
|
|
const rows = [];
|
|
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>`);
|
|
}
|
|
if (rows.length === 0) {
|
|
deviceTableBody.innerHTML = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Нет хостов с ping «вверх» для этого scan (или скан ещё не дошёл до них).</td></tr>';
|
|
return;
|
|
}
|
|
deviceTableBody.innerHTML = rows.join("");
|
|
} catch (e) {
|
|
deviceTableBody.innerHTML = `<tr><td colspan="4" style="padding:10px;color:#b91c1c;">Ошибка: ${escapeHtml(e.message)}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
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 drawTopology(data, highlight = null) {
|
|
clearSVG();
|
|
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;
|
|
});
|
|
}
|
|
const visibleIPs = new Set(nodes.map(n => n.ip));
|
|
const byIP = new Map();
|
|
nodes.forEach(n => byIP.set(n.ip, n));
|
|
|
|
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),
|
|
});
|
|
});
|
|
|
|
edges.forEach(e => {
|
|
const s = pos.get(e.source_ip);
|
|
if (!s) return;
|
|
if (!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 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");
|
|
svg.appendChild(line);
|
|
});
|
|
|
|
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", "16");
|
|
circle.setAttribute("fill", isNew ? "#16a34a" : (isMissing ? "#dc2626" : "#2563eb"));
|
|
circle.setAttribute("opacity", "0.9");
|
|
svg.appendChild(circle);
|
|
|
|
const txt = document.createElementNS(NS, "text");
|
|
txt.setAttribute("x", String(p.x + 22));
|
|
txt.setAttribute("y", String(p.y + 5));
|
|
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 += " [-]";
|
|
svg.appendChild(txt);
|
|
});
|
|
}
|
|
|
|
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", "0 0 1200 620");
|
|
});
|
|
nodeFilter.addEventListener("change", () => {
|
|
if (lastTopology) {
|
|
drawTopology(lastTopology, lastHighlight);
|
|
}
|
|
});
|
|
setDiff("Diff будет показан здесь...");
|
|
loadScanOptions();
|
|
</script>
|
|
</body>
|
|
</html>
|