635876bbe3
Made-with: Cursor
1507 lines
72 KiB
HTML
1507 lines
72 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; }
|
|
/* Стабильная высота при «Загрузка…», меньше сдвиг графа/легенды под панелью деталей */
|
|
#deviceDetailPanel .subtable-wrap { min-height: 140px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h2>NetTopo: просмотр топологии</h2>
|
|
<div class="hint">После скана нажмите «Загрузить»: граф раскладывается пружинной моделью по LLDP, стрелки — от локального порта к соседу; полный список связей — блок под графом. Таблица устройств: сортировка по заголовкам. <strong>Поиск MAC</strong> — блок с фиолетовой рамкой сразу под полем scan_id: на каком коммутаторе и порту виден адрес (FDB в БД).</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 id="macSearchPanel" style="margin:10px 0;padding:12px 14px;background:#eef2ff;border:2px solid #6366f1;border-radius:10px;box-shadow:0 1px 3px rgba(99,102,241,0.2);">
|
|
<div style="font-weight:700;font-size:15px;color:#1e1b4b;margin-bottom:6px;">Где этот MAC подключён? (таблица FDB по выбранному scan)</div>
|
|
<p class="hint" style="margin:0 0 10px;">Сначала укажите <strong>scan_id</strong> в поле выше (или «Последний scan» / «Загрузить»). Введите MAC. Строки сортируются: сначала порты с малым числом уникальных MAC на том же <strong>VLAN</strong> и порту (вероятный доступ), затем «между порогами», затем транк/uplink.</p>
|
|
<div class="row" style="align-items:center;flex-wrap:wrap;gap:8px;">
|
|
<label for="macSearchInput" style="font-weight:600;color:#312e81;">MAC</label>
|
|
<input id="macSearchInput" placeholder="aa:bb:cc:dd:ee:ff" style="min-width:260px;font-family:monospace;" autocomplete="off" />
|
|
<button type="button" id="macSearchBtn" style="background:#4f46e5;color:#fff;border:none;padding:8px 14px;border-radius:8px;cursor:pointer;font-weight:600;">Показать на каком коммутаторе</button>
|
|
<span id="macSearchSummary" class="hint" style="margin:0;"></span>
|
|
</div>
|
|
<div class="row" style="flex-wrap:wrap;gap:10px;align-items:center;font-size:13px;margin-top:4px;">
|
|
<span style="color:#4338ca;font-weight:600;">Пороги</span>
|
|
<label title="Если на порту (и VLAN) не больше столько уникальных MAC — считаем порт доступом">доступ ≤ <input type="number" id="macAccessMax" min="1" max="4096" value="8" style="width:56px;padding:4px 6px;" /></label>
|
|
<label title="Если не меньше столько уникальных MAC — сильный признак транка/uplink">транк ≥ <input type="number" id="macTrunkMin" min="2" max="65536" value="32" style="width:56px;padding:4px 6px;" /></label>
|
|
<span class="hint" style="margin:0;">(должно быть: доступ < транк)</span>
|
|
</div>
|
|
<div style="overflow:auto; max-height:260px; margin-top:12px; border:1px solid #c7d2fe; border-radius:8px;background:#fff;">
|
|
<table id="macSearchTable" style="width:100%; border-collapse:collapse; font-size:13px;">
|
|
<thead>
|
|
<tr style="background:#e0e7ff;">
|
|
<th style="text-align:left;padding:8px 10px;">Коммутатор (IP)</th>
|
|
<th style="text-align:left;padding:8px 10px;">Имя коммутатора</th>
|
|
<th style="text-align:left;padding:8px 10px;">ifIndex</th>
|
|
<th style="text-align:left;padding:8px 10px;">bridge</th>
|
|
<th style="text-align:left;padding:8px 10px;">VLAN</th>
|
|
<th style="text-align:left;padding:8px 10px;">MAC</th>
|
|
<th style="text-align:left;padding:8px 10px;">IP за портом</th>
|
|
<th style="text-align:left;padding:8px 10px;">Имя клиента</th>
|
|
<th style="text-align:left;padding:8px 10px;" title="Уникальных MAC на этом коммутаторе, ifIndex, bridge, VLAN">MAC на порту</th>
|
|
<th style="text-align:left;padding:8px 10px;">Оценка порта</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="macSearchTableBody">
|
|
<tr><td colspan="10" style="padding:10px;color:#64748b;">Введите MAC и нажмите кнопку (нужен scan_id в поле выше).</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</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">
|
|
<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;
|
|
/** Увеличивается при каждом новом pollScanUntilDone — снимает гонку createScan vs watchScan (чередование строк статуса). */
|
|
let scanPollSeq = 0;
|
|
|
|
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") {
|
|
const pingDone = job.stats?.hosts_ping_phase_done;
|
|
const proc = job.stats?.hosts_processed;
|
|
let phase = "";
|
|
if (t > 0 && (pingDone != null || proc != null)) {
|
|
const pd = pingDone != null ? pingDone : "?";
|
|
const pr = proc != null ? proc : "?";
|
|
phase = ` Этапы: ping+порты ${pd}/${t} адр.; полностью (SNMP/LLDP/FDB) ${pr}/${t}.`;
|
|
if (proc != null && proc < t) {
|
|
phase += ` Пока ${pr} < ${t} — идёт тяжёлый SNMP на оставшихся (часто коммутаторы с большим FDB), это нормально и может занять минуты.`;
|
|
}
|
|
}
|
|
return `Скан ${id}: обход сети — прогресс ~${p}% (из ${t} адресов); отвечают на ping: ${up}.${phase} Для отвечающих: порты → 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 mySeq = ++scanPollSeq;
|
|
const intervalMs = opts.intervalMs ?? 1000;
|
|
const maxWaitMs = opts.maxWaitMs ?? 20 * 60 * 1000;
|
|
const started = Date.now();
|
|
while (Date.now() - started < maxWaitMs) {
|
|
if (mySeq !== scanPollSeq) {
|
|
return null;
|
|
}
|
|
let job;
|
|
try {
|
|
job = await fetchScanJob(scanId);
|
|
} catch (e) {
|
|
if (mySeq === scanPollSeq) {
|
|
setStatus(`Ошибка опроса скана: ${e.message}`);
|
|
}
|
|
return null;
|
|
}
|
|
if (mySeq !== scanPollSeq) {
|
|
return null;
|
|
}
|
|
setStatus(describeScanProgress(job));
|
|
if (job.status === "done" || job.status === "failed" || job.status === "canceled") {
|
|
if (mySeq !== scanPollSeq) {
|
|
return null;
|
|
}
|
|
if (opts.onDone) {
|
|
try {
|
|
await opts.onDone(job);
|
|
} catch (e) {
|
|
if (mySeq === scanPollSeq) {
|
|
setStatus((statusEl.textContent || "") + ` | Ошибка в onDone: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
return job;
|
|
}
|
|
await sleep(intervalMs);
|
|
}
|
|
if (mySeq === scanPollSeq) {
|
|
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("");
|
|
updateDeviceRowSelectionHighlight();
|
|
}
|
|
|
|
/** Только подсветка строки — без пересборки таблицы (избегает дёрганья вёрстки/графа). */
|
|
function updateDeviceRowSelectionHighlight() {
|
|
deviceTableBody.querySelectorAll("tr.device-row").forEach((tr) => {
|
|
const ip = tr.getAttribute("data-ip");
|
|
tr.classList.toggle("selected", ip != null && ip === selectedDeviceIP);
|
|
});
|
|
}
|
|
|
|
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();
|
|
updateDeviceRowSelectionHighlight();
|
|
}
|
|
|
|
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;
|
|
updateDeviceRowSelectionHighlight();
|
|
|
|
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, "&")
|
|
.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(ы): один адрес, несколько через запятую или диапазон 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}`);
|
|
}
|
|
}
|
|
|
|
function macPortKindRu(kind) {
|
|
if (kind === "access") return "доступ (мало MAC)";
|
|
if (kind === "strong_transit") return "транк / uplink";
|
|
if (kind === "ambiguous") return "между порогами";
|
|
return kind ? String(kind) : "—";
|
|
}
|
|
|
|
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;
|
|
}
|
|
const amEl = document.getElementById("macAccessMax");
|
|
const tmEl = document.getElementById("macTrunkMin");
|
|
const accessMax = amEl && amEl.value !== "" ? parseInt(amEl.value, 10) : 8;
|
|
const trunkMin = tmEl && tmEl.value !== "" ? parseInt(tmEl.value, 10) : 32;
|
|
if (!Number.isFinite(accessMax) || !Number.isFinite(trunkMin) || accessMax < 1 || trunkMin < 2) {
|
|
setStatus("Пороги: введите числа (доступ ≥1, транк ≥2).");
|
|
return;
|
|
}
|
|
if (accessMax >= trunkMin) {
|
|
setStatus("Пороги: «доступ» должно быть строго меньше «транк» (например 8 и 32).");
|
|
return;
|
|
}
|
|
if (macSearchSummary) macSearchSummary.textContent = "";
|
|
if (macSearchTableBody) {
|
|
macSearchTableBody.innerHTML =
|
|
'<tr><td colspan="10" style="padding:8px;color:#64748b;">Загрузка…</td></tr>';
|
|
}
|
|
setStatus(`Поиск MAC в scan ${scanId}…`);
|
|
try {
|
|
let url = `/api/scans/${encodeURIComponent(scanId)}/mac-locations?mac=${encodeURIComponent(
|
|
mac
|
|
)}&limit=2000&access_max_macs=${encodeURIComponent(String(accessMax))}&trunk_min_macs=${encodeURIComponent(
|
|
String(trunkMin)
|
|
)}`;
|
|
const res = await fetch(url);
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
if (macSearchTableBody) {
|
|
macSearchTableBody.innerHTML = `<tr><td colspan="10" 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 likely = data.likely_attachment || [];
|
|
const canon = data.mac || mac;
|
|
const am = data.access_max_macs ?? accessMax;
|
|
const tm = data.trunk_min_macs ?? trunkMin;
|
|
if (macSearchSummary) {
|
|
if (!items.length) {
|
|
macSearchSummary.textContent = `Нормализовано: ${canon} · в FDB этого скана нет`;
|
|
} else {
|
|
macSearchSummary.textContent = `Пороги: доступ ≤${am}, транк ≥${tm}. Вероятный доступ: ${likely.length} из ${items.length} строк (см. колонки).`;
|
|
}
|
|
}
|
|
if (!macSearchTableBody) return;
|
|
if (items.length === 0) {
|
|
macSearchTableBody.innerHTML =
|
|
'<tr><td colspan="10" 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><td>${escapeHtml(
|
|
String(row.macs_on_port ?? "")
|
|
)}</td><td>${escapeHtml(macPortKindRu(row.port_kind))}</td></tr>`
|
|
)
|
|
.join("");
|
|
setStatus(`Поиск MAC: ${items.length} строк (${canon}), вероятный доступ: ${likely.length}.`);
|
|
} catch (e) {
|
|
if (macSearchTableBody) {
|
|
macSearchTableBody.innerHTML = `<tr><td colspan="10" 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>
|