Files
nettopo-go/internal/webui/index.html
T
2026-04-13 12:54:31 +10:00

1432 lines
66 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; }
tr.port-row { cursor: pointer; }
tr.port-row:hover { background: #f1f5f9; }
tr.port-row.selected { background: #dbeafe; box-shadow: inset 0.15em 0 0 #2563eb; }
tr.fdb-row { cursor: pointer; }
tr.fdb-row:hover { background: #f1f5f9; }
tr.fdb-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, список через запятую или диапазон: 172.16.0.0/24,192.168.160.0/24" style="min-width: 480px;" />
<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>
<details class="admin-purge" style="margin:10px 0;padding:8px 12px;background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;max-width:720px;">
<summary style="cursor:pointer;font-weight:600;color:#9a3412;">Админ: начать всё заново — очистить данные сканов в БД</summary>
<p class="hint" style="margin:8px 0;">На сервере должен быть задан <code>NETTOPO_RESET_DB_SECRET</code>. Ключ ниже передаётся только в заголовке запроса и нигде не сохраняется.</p>
<div class="row" style="flex-wrap:wrap;gap:8px;align-items:center;">
<input type="password" id="purgeSecretInput" placeholder="секрет (X-Nettopo-Reset-Key)" style="min-width:260px;" autocomplete="off" />
<button type="button" id="purgeDbBtn" style="background:#b91c1c;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;">Удалить все сканы и результаты</button>
</div>
</details>
<div id="status">Ожидание действия. Строка статуса показывает текущую операцию и прогресс скана.</div>
<div class="panel" id="macSearchPanel" style="background:#fafafa;border:1px solid #e5e7eb;border-radius:8px;padding:10px 12px;">
<div class="row" style="margin-bottom:8px;align-items:center;">
<strong>Поиск MAC в сохранённом скане</strong>
<span class="hint" style="margin:0;">По FDB в БД: коммутатор (IP коммутатора из скана), порт (ifIndex / bridge), VLAN; IP и имя клиента — из ARP на коммутаторе и SNMP этого scan.</span>
</div>
<div class="row">
<input id="macSearchInput" placeholder="MAC, напр. aa:99:0f:91:76:76" style="min-width:280px;" autocomplete="off" />
<button type="button" id="macSearchBtn">Найти</button>
<span id="macSearchSummary" class="hint" style="margin:0;"></span>
</div>
<div style="overflow:auto; max-height:240px; margin-top:8px; border:1px solid #e5e7eb; border-radius:8px;">
<table id="macSearchTable" style="width:100%; border-collapse:collapse; font-size:13px;">
<thead>
<tr style="background:#f1f5f9;">
<th style="text-align:left;padding:6px 8px;">Коммутатор (IP)</th>
<th style="text-align:left;padding:6px 8px;">Имя (sysName)</th>
<th style="text-align:left;padding:6px 8px;">ifIndex</th>
<th style="text-align:left;padding:6px 8px;">bridge</th>
<th style="text-align:left;padding:6px 8px;">VLAN</th>
<th style="text-align:left;padding:6px 8px;">MAC</th>
<th style="text-align:left;padding:6px 8px;">IP за портом</th>
<th style="text-align:left;padding:6px 8px;">Имя клиента</th>
</tr>
</thead>
<tbody id="macSearchTableBody">
<tr><td colspan="8" style="padding:8px;color:#64748b;">Укажите scan_id выше и MAC, нажмите «Найти».</td></tr>
</tbody>
</table>
</div>
</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;">Кликните по строке в таблице «Найденные устройства». Таблица 1 — все интерфейсы из IF-MIB (SNMP) и LLDP-соседи по ifIndex; сортировка по «Локальный порт». Колонки MAC/IP за портом — из FDB+ARP этого коммутатора (IP только если он есть в ARP на свитче). Клик по строке Таблицы 1 выделяет порт и заполняет Таблицу 2.</p>
<div style="margin-bottom: 14px;">
<div style="font-weight:600; margin-bottom:4px; color:#334155;">Таблица 1. Связи (IF-MIB + LLDP по портам)</div>
<div class="subtable-wrap">
<table class="device-detail-table" id="deviceLinksTable" style="width:100%; border-collapse:collapse;">
<thead id="deviceLinksTableHead">
<tr style="background:#f1f5f9;">
<th class="sortable-th-link" data-sort-link="port" style="text-align:left; cursor:pointer;">Локальный порт <span class="sort-ind-link"></span></th>
<th style="text-align:left;">SNMP (интерфейс)</th>
<th style="text-align:left;" title="MAC из FDB за портом (ifIndex / bridge port)">MAC за портом</th>
<th style="text-align:left;" title="IP из ARP на этом коммутаторе, если агент SNMP его отдаёт">IP (ARP)</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="9" style="padding:10px;color:#64748b;">Выберите устройство выше.</td></tr>
</tbody>
</table>
</div>
</div>
<div>
<div style="font-weight:600; margin-bottom:4px; color:#334155;">Таблица 2. Устройства за портом (VLAN, MAC, IP, имя — как MAC table; IP/имя при наличии ARP и строки SNMP в этом scan)</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;">ifIndex</th>
<th style="text-align:left;">VLAN</th>
<th style="text-align:left;">MAC</th>
<th style="text-align:left;">IP</th>
<th style="text-align:left;">Имя (sysName)</th>
</tr>
</thead>
<tbody id="deviceLldpTextTableBody">
<tr><td colspan="5" style="padding:10px;color:#64748b;">Выберите устройство и порт в Таблице 1.</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 macSearchInput = document.getElementById("macSearchInput");
const macSearchBtn = document.getElementById("macSearchBtn");
const macSearchTableBody = document.getElementById("macSearchTableBody");
const macSearchSummary = document.getElementById("macSearchSummary");
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";
/** @type {{sortKey:number,localPort:string,snmpInfo:string,fdbMac:string,fdbIp:string,targetName:string,targetIP:string,targetPort:string,chassis:string,resolved:string}[]} */
let deviceLinksRowsCache = [];
let deviceLinksSort = { key: "port", dir: 1 };
let selectedIfIndex = null;
/** @type {{if_index:number,bridge_port:number,mac:string,learned_ip:string}[]} */
let lastPortDevItems = [];
let selectedFdbKey = 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;
}
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 deviceLinksTableHead = document.getElementById("deviceLinksTableHead");
const deviceLldpTextTableBody = document.getElementById("deviceLldpTextTableBody");
const selectedDeviceLabel = document.getElementById("selectedDeviceLabel");
function clearDeviceDetailPanel() {
selectedDeviceIP = null;
selectedIfIndex = null;
lastPortDevItems = [];
selectedFdbKey = null;
selectedDeviceLabel.textContent = "—";
deviceLinksRowsCache = [];
deviceLinksTableBody.innerHTML =
'<tr><td colspan="9" style="padding:10px;color:#64748b;">Выберите устройство в таблице выше.</td></tr>';
deviceLldpTextTableBody.innerHTML =
'<tr><td colspan="5" style="padding:10px;color:#64748b;">Выберите устройство и порт в Таблице 1.</td></tr>';
updateDeviceLinksSortIndicators();
}
function resolvedLabelRu(resolvedBy) {
if (resolvedBy === "remote_sys_name") return "по sysName → IP в скане";
if (resolvedBy === "unresolved") return "IP не найден среди просканированных";
return resolvedBy || "—";
}
function formatSnmpIfaceRow(i) {
const parts = [`ifIndex ${i.if_index}`];
if (i.if_name) parts.push(i.if_name);
if (i.if_descr) parts.push(i.if_descr);
if (i.if_oper_status) parts.push(`(${i.if_oper_status})`);
return parts.join(" · ");
}
/** Числовой ifIndex из локального порта LLDP (префикс до « — » или «-»). */
function parseLldpLocalIfIndex(sourcePort) {
if (!sourcePort) return null;
const head = String(sourcePort).trim().split(/\s*[\u2014\-]\s*/)[0].trim();
const m = head.match(/^(\d+)/);
return m ? parseInt(m[1], 10) : null;
}
function normalizeMacLocal(m) {
return String(m || "")
.toLowerCase()
.replace(/-/g, ":")
.trim();
}
function aggregatePortFdbSummary(sortKey, portItems) {
if (!portItems || !sortKey || sortKey <= 0 || sortKey >= 1e9) {
return { macText: "—", ipText: "—" };
}
const hits = [];
for (const p of portItems) {
const br = Number(p.bridge_port) || 0;
const ifi = Number(p.if_index) || 0;
if (ifi === sortKey || br === sortKey) hits.push(p);
}
const byVlanMac = new Map();
for (const p of hits) {
const mk = normalizeMacLocal(p.mac);
if (!mk) continue;
const vlan = Number(p.vlan) || 0;
const k = `${vlan}|${mk}`;
const ip = String(p.learned_ip || "").trim();
const cur = byVlanMac.get(k);
const macDisp = String(p.mac || "").trim() || mk;
if (!cur || (ip && !cur.ip)) byVlanMac.set(k, { mac: macDisp, vlan, ip });
}
const rows = [...byVlanMac.values()];
const n = rows.length;
if (n === 0) return { macText: "—", ipText: "—" };
if (n === 1) {
const r = rows[0];
const macShow = r.vlan ? `${r.mac} (VLAN ${r.vlan})` : r.mac;
return { macText: macShow, ipText: r.ip || "—" };
}
return { macText: `несколько MAC/VLAN (${n})`, ipText: "—" };
}
function attachFdbSummaries(rows, portItems) {
for (const r of rows) {
const s = aggregatePortFdbSummary(r.sortKey, portItems);
r.fdbMac = s.macText;
r.fdbIp = s.ipText;
}
}
function buildMergedPortRows(interfaces, links) {
const byIdx = new Map();
for (const L of links) {
const idx = parseLldpLocalIfIndex(L.source_port);
if (idx == null || !Number.isFinite(idx)) continue;
if (!byIdx.has(idx)) byIdx.set(idx, []);
byIdx.get(idx).push(L);
}
const rows = [];
const seenIfaceIdx = new Set();
if (interfaces && interfaces.length > 0) {
const sortedIf = [...interfaces].sort((a, b) => a.if_index - b.if_index);
for (const iface of sortedIf) {
const idx = iface.if_index;
seenIfaceIdx.add(idx);
const snmpText = formatSnmpIfaceRow(iface);
const ll = byIdx.get(idx) || [];
const localLabel = iface.if_name || iface.if_descr || `if${idx}`;
if (ll.length === 0) {
rows.push({
sortKey: idx,
localPort: localLabel,
snmpInfo: snmpText,
targetName: "—",
targetIP: "—",
targetPort: "—",
chassis: "—",
resolved: "нет LLDP на этом ifIndex",
});
} else {
for (const L of ll) {
rows.push({
sortKey: idx,
localPort: L.source_port || localLabel,
snmpInfo: snmpText,
targetName: L.target_sys_name || "—",
targetIP: L.target_ip || "—",
targetPort: L.target_port || "—",
chassis: L.remote_chassis_id || "—",
resolved: resolvedLabelRu(L.resolved_by),
});
}
}
}
}
for (const [idx, list] of byIdx) {
if (interfaces && interfaces.length > 0 && seenIfaceIdx.has(idx)) continue;
for (const L of list) {
rows.push({
sortKey: idx,
localPort: L.source_port || `if${idx}`,
snmpInfo: "—",
targetName: L.target_sys_name || "—",
targetIP: L.target_ip || "—",
targetPort: L.target_port || "—",
chassis: L.remote_chassis_id || "—",
resolved: resolvedLabelRu(L.resolved_by),
});
}
}
for (const L of links) {
const idx = parseLldpLocalIfIndex(L.source_port);
if (idx != null && Number.isFinite(idx)) continue;
rows.push({
sortKey: 1e9,
localPort: L.source_port || "—",
snmpInfo: "—",
targetName: L.target_sys_name || "—",
targetIP: L.target_ip || "—",
targetPort: L.target_port || "—",
chassis: L.remote_chassis_id || "—",
resolved: resolvedLabelRu(L.resolved_by),
});
}
return rows;
}
function updateDeviceLinksSortIndicators() {
if (!deviceLinksTableHead) return;
deviceLinksTableHead.querySelectorAll("[data-sort-link]").forEach((th) => {
const sp = th.querySelector(".sort-ind-link");
if (!sp) return;
const k = th.getAttribute("data-sort-link");
if (deviceLinksSort.key !== k) {
sp.textContent = "";
return;
}
sp.textContent = deviceLinksSort.dir > 0 ? " ▲" : " ▼";
});
}
function updatePortRowSelectionHighlight() {
deviceLinksTableBody.querySelectorAll("tr.port-row").forEach((tr) => {
const idx = parseInt(tr.getAttribute("data-if-index") || "", 10);
tr.classList.toggle("selected", selectedIfIndex != null && Number.isFinite(idx) && idx === selectedIfIndex);
});
}
function renderDeviceLinksTableBody() {
const arr = [...deviceLinksRowsCache];
arr.sort((a, b) => {
let cmp = 0;
if (deviceLinksSort.key === "port") {
cmp = (a.sortKey || 0) - (b.sortKey || 0);
if (cmp === 0) cmp = String(a.localPort).localeCompare(String(b.localPort), "ru", { sensitivity: "base" });
}
return cmp * deviceLinksSort.dir;
});
if (arr.length === 0) {
deviceLinksTableBody.innerHTML =
'<tr><td colspan="9" style="padding:10px;color:#64748b;">Нет строк для отображения.</td></tr>';
return;
}
deviceLinksTableBody.innerHTML = arr
.map(
(row) =>
`<tr class="port-row" data-if-index="${escapeHtml(row.sortKey)}"><td>${escapeHtml(row.localPort)}</td><td style="font-size:12px;color:#475569;">${escapeHtml(row.snmpInfo)}</td><td>${escapeHtml(row.fdbMac || "—")}</td><td>${escapeHtml(row.fdbIp || "—")}</td><td>${escapeHtml(row.targetName)}</td><td>${escapeHtml(row.targetIP)}</td><td>${escapeHtml(row.targetPort)}</td><td>${escapeHtml(row.chassis)}</td><td>${escapeHtml(row.resolved)}</td></tr>`
)
.join("");
updatePortRowSelectionHighlight();
}
function renderFdbTable2Body(items) {
selectedFdbKey = null;
if (!items || items.length === 0) {
deviceLldpTextTableBody.innerHTML =
'<tr><td colspan="5" style="padding:10px;color:#64748b;">За этим портом MAC/IP не найдены (или устройство не отдаёт BRIDGE-MIB/ARP).</td></tr>';
return;
}
const sorted = [...items].sort((a, b) => {
const va = Number(a.vlan) || 0;
const vb = Number(b.vlan) || 0;
if (va !== vb) return va - vb;
const ma = String(a.mac || "").localeCompare(String(b.mac || ""));
if (ma !== 0) return ma;
return String(a.learned_ip || "").localeCompare(String(b.learned_ip || ""));
});
deviceLldpTextTableBody.innerHTML = sorted
.map((row) => {
const mk = normalizeMacLocal(row.mac);
const ip = String(row.learned_ip || "").trim();
const vlan = Number(row.vlan) || 0;
const key = `${vlan}|${mk}|${ip}`;
const host = String(row.hostname || "").trim();
return `<tr class="fdb-row" data-fdb-key="${escapeHtml(key)}"><td>${escapeHtml(String(row.if_index ?? ""))}</td><td>${escapeHtml(String(vlan))}</td><td>${escapeHtml(row.mac || "—")}</td><td>${escapeHtml(row.learned_ip || "—")}</td><td>${escapeHtml(host || "—")}</td></tr>`;
})
.join("");
}
function loadPortDevicesForSelectedPort() {
if (!selectedDeviceIP || selectedIfIndex == null) return;
deviceLldpTextTableBody.innerHTML = '<tr><td colspan="5" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
try {
const items = lastPortDevItems.filter(
(p) => Number(p.if_index) === selectedIfIndex || Number(p.bridge_port) === selectedIfIndex
);
renderFdbTable2Body(items);
} catch (e) {
deviceLldpTextTableBody.innerHTML = `<tr><td colspan="5" style="padding:10px;color:#b91c1c;">${escapeHtml(
e.message
)}</td></tr>`;
}
}
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="9" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
deviceLldpTextTableBody.innerHTML = '<tr><td colspan="5" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
try {
const [linksRes, ifRes, pdRes] = await Promise.all([
fetch(`/api/scans/${encodeURIComponent(scanId)}/links?limit=20000`),
fetch(
`/api/scans/${encodeURIComponent(scanId)}/interfaces?ip=${encodeURIComponent(ip)}&limit=8192`
),
fetch(
`/api/scans/${encodeURIComponent(scanId)}/port-devices?ip=${encodeURIComponent(ip)}&limit=20000`
),
]);
if (!linksRes.ok) throw new Error("links: " + (await linksRes.text()));
const linkItems = (await linksRes.json()).items || [];
let ifItems = [];
if (ifRes.ok) {
ifItems = ((await ifRes.json()) || {}).items || [];
}
lastPortDevItems = pdRes.ok ? ((await pdRes.json()) || {}).items || [] : [];
const myLinks = linkItems.filter((x) => x.source_ip === ip);
deviceLinksRowsCache = buildMergedPortRows(ifItems, myLinks);
attachFdbSummaries(deviceLinksRowsCache, lastPortDevItems);
updateDeviceLinksSortIndicators();
if (deviceLinksRowsCache.length === 0) {
deviceLinksTableBody.innerHTML =
'<tr><td colspan="9" style="padding:10px;color:#64748b;">Нет данных: для этого IP нет интерфейсов IF-MIB и нет строк /links (пересканируйте после обновления сервера для сбора IF-MIB).</td></tr>';
} else {
renderDeviceLinksTableBody();
}
selectedIfIndex = null;
selectedFdbKey = null;
deviceLldpTextTableBody.innerHTML =
'<tr><td colspan="5" style="padding:10px;color:#64748b;">Выберите строку в Таблице 1, чтобы увидеть VLAN/MAC/IP за портом.</td></tr>';
setStatus(
`Связи для ${ip}: строк табл.1 ${deviceLinksRowsCache.length} (IF-MIB ${ifItems.length}, /links ${myLinks.length}, FDB строк ${lastPortDevItems.length}).`
);
} catch (e) {
deviceLinksTableBody.innerHTML = `<tr><td colspan="9" style="padding:10px;color:#b91c1c;">${escapeHtml(e.message)}</td></tr>`;
deviceLldpTextTableBody.innerHTML = `<tr><td colspan="5" style="padding:10px;color:#b91c1c;">${escapeHtml(e.message)}</td></tr>`;
}
}
if (deviceLinksTableHead) {
deviceLinksTableHead.addEventListener("click", (ev) => {
const th = ev.target.closest("[data-sort-link]");
if (!th || !deviceLinksTableHead.contains(th)) return;
const key = th.getAttribute("data-sort-link");
if (!key) return;
if (deviceLinksSort.key === key) deviceLinksSort.dir *= -1;
else {
deviceLinksSort.key = key;
deviceLinksSort.dir = 1;
}
renderDeviceLinksTableBody();
updateDeviceLinksSortIndicators();
});
}
deviceLinksTableBody.addEventListener("click", (ev) => {
const tr = ev.target.closest("tr.port-row");
if (!tr) return;
const idx = parseInt(tr.getAttribute("data-if-index") || "", 10);
if (!Number.isFinite(idx) || idx <= 0 || idx >= 1e9) {
setStatus("Для этой строки нет корректного ifIndex — выберите интерфейсную строку.");
return;
}
selectedIfIndex = idx;
loadPortDevicesForSelectedPort();
updatePortRowSelectionHighlight();
});
deviceLldpTextTableBody.addEventListener("click", (ev) => {
const tr = ev.target.closest("tr.fdb-row");
if (!tr) return;
const k = tr.getAttribute("data-fdb-key") || "";
selectedFdbKey = k;
deviceLldpTextTableBody.querySelectorAll("tr.fdb-row").forEach((r) => {
r.classList.toggle("selected", (r.getAttribute("data-fdb-key") || "") === k);
});
});
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=200000`),
fetch(`/api/scans/${encodeURIComponent(scanId)}/snmp?limit=200000`),
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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(ы): один адрес, несколько через запятую или диапазон IPv4 (192.168.160.0/24 - 192.168.190.0/24).");
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}`);
}
}
async function runMacSearch() {
const scanId = scanIdInput.value.trim();
const mac = (macSearchInput && macSearchInput.value.trim()) || "";
if (!scanId) {
setStatus("Укажите scan_id для поиска MAC.");
return;
}
if (!mac) {
setStatus("Введите MAC-адрес.");
return;
}
if (macSearchSummary) macSearchSummary.textContent = "";
if (macSearchTableBody) {
macSearchTableBody.innerHTML =
'<tr><td colspan="8" style="padding:8px;color:#64748b;">Загрузка…</td></tr>';
}
setStatus(`Поиск MAC в scan ${scanId}`);
try {
const res = await fetch(
`/api/scans/${encodeURIComponent(scanId)}/mac-locations?mac=${encodeURIComponent(mac)}&limit=2000`
);
const text = await res.text();
if (!res.ok) {
if (macSearchTableBody) {
macSearchTableBody.innerHTML = `<tr><td colspan="8" style="padding:8px;color:#b91c1c;">${escapeHtml(
text || res.statusText
)}</td></tr>`;
}
setStatus(`Ошибка поиска MAC: ${res.status}`);
return;
}
const data = JSON.parse(text);
const items = data.items || [];
const canon = data.mac || mac;
if (macSearchSummary) {
macSearchSummary.textContent = items.length
? `Нормализовано: ${canon} · записей: ${items.length}`
: `Нормализовано: ${canon} · в FDB этого скана нет`;
}
if (!macSearchTableBody) return;
if (items.length === 0) {
macSearchTableBody.innerHTML =
'<tr><td colspan="8" style="padding:8px;color:#64748b;">Нет совпадений в scan_port_devices.</td></tr>';
setStatus(`Поиск MAC: совпадений нет (${canon}).`);
return;
}
macSearchTableBody.innerHTML = items
.map(
(row) =>
`<tr><td>${escapeHtml(row.switch_ip || "")}</td><td>${escapeHtml(
row.switch_sys_name || "—"
)}</td><td>${escapeHtml(String(row.if_index ?? ""))}</td><td>${escapeHtml(
String(row.bridge_port ?? "")
)}</td><td>${escapeHtml(String(row.vlan ?? ""))}</td><td>${escapeHtml(
row.mac || "—"
)}</td><td>${escapeHtml(row.client_ip || "—")}</td><td>${escapeHtml(row.client_name || "—")}</td></tr>`
)
.join("");
setStatus(`Поиск MAC: найдено ${items.length} строк (${canon}).`);
} catch (e) {
if (macSearchTableBody) {
macSearchTableBody.innerHTML = `<tr><td colspan="8" style="padding:8px;color:#b91c1c;">${escapeHtml(
e.message
)}</td></tr>`;
}
setStatus(`Ошибка поиска MAC: ${e.message}`);
}
}
loadBtn.addEventListener("click", async () => {
await loadTopology();
await loadDeviceTable(scanIdInput.value.trim());
});
refreshDevicesBtn.addEventListener("click", () => loadDeviceTable(scanIdInput.value.trim()));
macSearchBtn?.addEventListener("click", runMacSearch);
macSearchInput?.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") runMacSearch();
});
createScanBtn.addEventListener("click", createScan);
cidrInput?.addEventListener("keydown", (ev) => {
if (ev.key !== "Enter") return;
ev.preventDefault();
createScan();
});
document.getElementById("purgeDbBtn")?.addEventListener("click", async () => {
const inp = document.getElementById("purgeSecretInput");
const secret = (inp && inp.value.trim()) || "";
if (!secret) {
setStatus("Введите секрет (тот же, что NETTOPO_RESET_DB_SECRET на сервере).");
return;
}
if (
!confirm(
"Удалить ВСЕ задачи скана и связанные данные (хосты, порты, SNMP, LLDP, интерфейсы)? Это необратимо."
)
) {
return;
}
setStatus("Очистка данных сканов…");
try {
const res = await fetch("/api/admin/purge-scans", {
method: "POST",
headers: { "X-Nettopo-Reset-Key": secret },
});
const text = await res.text();
if (!res.ok) {
setStatus(`Ошибка очистки: ${res.status} ${text}`);
return;
}
scanIdInput.value = "";
fromScanIdInput.value = "";
toScanIdInput.value = "";
clearDeviceDetailPanel();
deviceRowsCache = [];
deviceTableBody.innerHTML =
'<tr><td colspan="5" style="padding:10px;color:#64748b;">Нет scan_id. Загрузите после нового скана.</td></tr>';
lastTopology = null;
lastDiff = null;
lastHighlight = null;
clearSVG();
edgeListPanel.style.display = "none";
diffBox.textContent = "";
await loadScanOptions();
setStatus("Данные сканов удалены. Создайте новый scan или выберите из списка.");
} catch (e) {
setStatus(`Ошибка: ${e.message}`);
}
});
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>