diff --git a/README.md b/README.md index 620909f..489f0a9 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,20 @@ http://localhost:8080/ По умолчанию используется `STORE_BACKEND=memory`. SNMP-проверка по умолчанию включена (`SNMP_ENABLED=true`, community `public`). +## Логика одного scan (что делает программа) + +Один запуск scan — это **одна фоновая задача** (`runner`), без отдельных фаз «только ping» / «только LLDP» в API. + +1. По полю `cidrs` строится список IP (с учётом `exclude_ips`). +2. Параллельно (ограничение `max_parallel_hosts`) для каждого IP: + - **ping** (ICMP через системную утилиту `ping`); + - если хост «вверх» и включён port-scan — проверка **TCP-портов** из `options.ports`; + - если хост «вверх» и `SNMP_ENABLED=true` — **SNMP v2c** (sysName, sysDescr, sysObjectID) и сразу **обход LLDP-MIB** (соседи, локальный/удалённый порт и т.д.). Это не отдельная кнопка: LLDP выполняется в том же проходе, что и базовый SNMP. +3. Статус задачи: `queued` → `running` (поле `progress` 0…100, `stats.hosts_total` / `stats.hosts_up`) → `done` или `failed`. +4. Результаты читаются через API: `/hosts`, `/ports`, `/snmp`, `/lldp`, агрегированный граф — `/topology`. + +В веб-UI после **«Создать scan и ждать»** опрашивается статус, показывается расшифровка в строке состояния, по завершении обновляется таблица **найденных устройств** (ping + SNMP) и строится граф по кнопке **«Загрузить»** (или автоматически после ожидания). + ## PostgreSQL без Docker ### Вариант A: macOS (Homebrew) diff --git a/internal/webui/index.html b/internal/webui/index.html index 4891d87..bb94eb0 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -6,20 +6,27 @@ NetTopo UI

NetTopo: просмотр топологии

-
Можно выбрать последний scan автоматически или сравнить два скана через встроенный diff.
+
После скана нажмите «Загрузить», чтобы нарисовать граф по данным LLDP/SNMP.
+
+ Один scan = один полный проход по сети. Для каждого IP из CIDR (с исключениями): ping → для ответивших — проверка TCP-портов → если включён SNMP — опрос системы (sysName и др.) и сразу обход LLDP-MIB (соседи по портам). Отдельной кнопки «только LLDP» нет: соседи попадают в этот же цикл. Кнопка «Создать scan» ставит задачу и сама ждёт завершения, показывая прогресс в строке статуса ниже. +
- - + +
@@ -37,7 +44,31 @@
-
Ожидание данных...
+
Ожидание действия. Строка статуса показывает текущую операцию и прогресс скана.
+ +
+
+ Найденные устройства + отвечают на ping; имя — из SNMP при успехе. Связи между устройствами — в графе («Загрузить»). + +
+
+ + + + + + + + + + + + +
IPPingИмя (SNMP)SNMP
Укажите scan_id или создайте scan.
+
+
+
Легенда: ● синий - обычный узел @@ -73,12 +104,122 @@ const statusEl = document.getElementById("status"); const diffBox = document.getElementById("diffBox"); const svg = document.getElementById("canvas"); + const deviceTableBody = document.getElementById("deviceTableBody"); + const refreshDevicesBtn = document.getElementById("refreshDevicesBtn"); const NS = "http://www.w3.org/2000/svg"; let lastTopology = null; let lastHighlight = null; let lastDiff = null; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + function setStatus(text) { statusEl.textContent = text; } + + async function fetchScanJob(id) { + const res = await fetch(`/api/scans/${encodeURIComponent(id)}`); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); + return res.json(); + } + + /** Человекочитаемое описание задачи для строки статуса */ + function describeScanProgress(job) { + const id = job.id || ""; + const st = job.status || ""; + const p = job.progress ?? 0; + const t = job.stats?.hosts_total ?? 0; + const up = job.stats?.hosts_up ?? 0; + if (st === "queued") { + return `Задача ${id}: в очереди, подготовка списка адресов…`; + } + if (st === "running") { + return `Скан ${id}: выполняется обход сети — обработано ~${p}% адресов из ${t}; отвечают на ping (уже проверены): ${up}. Для каждого отвечающего: порты → SNMP → LLDP.`; + } + if (st === "done") { + return `Скан ${id}: завершён. Всего адресов в задании: ${t}, ответили на ping: ${up}.`; + } + if (st === "failed") { + return `Скан ${id}: помечен как failed (смотрите journalctl на сервере).`; + } + return `Скан ${id}: статус «${st}», прогресс ${p}%.`; + } + + /** + * Опрашивает /api/scans/{id} до done/failed или таймаута. + * @param {{ intervalMs?: number, maxWaitMs?: number, onDone?: function }} opts + */ + async function pollScanUntilDone(scanId, opts = {}) { + const intervalMs = opts.intervalMs ?? 1000; + const maxWaitMs = opts.maxWaitMs ?? 20 * 60 * 1000; + const started = Date.now(); + while (Date.now() - started < maxWaitMs) { + let job; + try { + job = await fetchScanJob(scanId); + } catch (e) { + setStatus(`Ошибка опроса скана: ${e.message}`); + return null; + } + setStatus(describeScanProgress(job)); + if (job.status === "done" || job.status === "failed" || job.status === "canceled") { + if (opts.onDone) { + try { + await opts.onDone(job); + } catch (e) { + setStatus((statusEl.textContent || "") + ` | Ошибка в onDone: ${e.message}`); + } + } + return job; + } + await sleep(intervalMs); + } + setStatus(`Таймаут ожидания скана ${scanId} (${Math.round(maxWaitMs / 60000)} мин).`); + return null; + } + + async function loadDeviceTable(scanId) { + if (!scanId) { + deviceTableBody.innerHTML = 'Нет scan_id.'; + return; + } + deviceTableBody.innerHTML = 'Загрузка…'; + try { + const [hRes, sRes] = await Promise.all([ + fetch(`/api/scans/${encodeURIComponent(scanId)}/hosts?limit=5000`), + fetch(`/api/scans/${encodeURIComponent(scanId)}/snmp?limit=5000`), + ]); + if (!hRes.ok) throw new Error(await hRes.text()); + if (!sRes.ok) throw new Error(await sRes.text()); + const hData = await hRes.json(); + const sData = await sRes.json(); + const snmpByIp = new Map(); + for (const r of sData.items || []) { + snmpByIp.set(r.ip, r); + } + const rows = []; + for (const h of hData.items || []) { + if (!h.is_up) continue; + const s = snmpByIp.get(h.ip); + const name = s && s.success ? (s.sys_name || "(пусто)") : "—"; + const snmpCell = s ? (s.success ? "OK" : (s.error || "нет ответа")) : "не опрашивался"; + rows.push(`${escapeHtml(h.ip)}да${escapeHtml(name)}${escapeHtml(snmpCell)}`); + } + if (rows.length === 0) { + deviceTableBody.innerHTML = 'Нет хостов с ping «вверх» для этого scan (или скан ещё не дошёл до них).'; + return; + } + deviceTableBody.innerHTML = rows.join(""); + } catch (e) { + deviceTableBody.innerHTML = `Ошибка: ${escapeHtml(e.message)}`; + } + } + + function escapeHtml(s) { + return String(s) + .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" }); @@ -227,10 +368,10 @@ async function createScan() { const cidr = cidrInput.value.trim(); if (!cidr) { - setStatus("Введите CIDR для создания scan"); + setStatus("Введите CIDR (подсеть для сканирования)."); return; } - setStatus("Создаю scan..."); + setStatus("Отправка запроса на сервер: создание задачи scan…"); try { const payload = { name: `manual-${new Date().toISOString()}`, @@ -255,10 +396,24 @@ return; } const d = await res.json(); - scanIdInput.value = d.scan_id; - toScanIdInput.value = d.scan_id; - setStatus(`Scan создан: ${d.scan_id}`); + 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}`); } @@ -267,33 +422,23 @@ async function watchScan() { const id = scanIdInput.value.trim(); if (!id) { - setStatus("Введите scan_id"); + setStatus("Введите scan_id или выберите из списка."); return; } - setStatus(`Ожидание завершения scan ${id}...`); - const startedAt = Date.now(); - const maxWaitMs = 20 * 60 * 1000; - while (Date.now() - startedAt < maxWaitMs) { - try { - const res = await fetch(`/api/scans/${encodeURIComponent(id)}`); - if (!res.ok) { - setStatus(`Ошибка чтения scan: ${res.status}`); - return; - } - const s = await res.json(); - setStatus(`Scan ${id}: ${s.status}, progress ${s.progress || 0}%`); - if (s.status === "done" || s.status === "failed" || s.status === "canceled") { + setStatus(`Подключаюсь к задаче ${id}, буду опрашивать статус…`); + await pollScanUntilDone(id, { + intervalMs: 1500, + onDone: async (job) => { + await loadScanOptions(); + setStatus(describeScanProgress(job)); + if (job.status === "done") { + await loadDeviceTable(id); await loadTopology(); - await loadScanOptions(); - return; + } else if (job.status === "failed") { + await loadDeviceTable(id); } - } catch (e) { - setStatus(`Ошибка ожидания scan: ${e.message}`); - return; - } - await new Promise(resolve => setTimeout(resolve, 2000)); - } - setStatus("Таймаут ожидания scan"); + }, + }); } async function loadLatestScan() { @@ -313,6 +458,7 @@ scanIdInput.value = latest.id; toScanIdInput.value = latest.id; setStatus(`Найден последний scan: ${latest.id}`); + await loadDeviceTable(latest.id); await loadTopology(); } catch (e) { setStatus(`Ошибка загрузки последнего scan: ${e.message}`); @@ -357,7 +503,11 @@ } } - loadBtn.addEventListener("click", loadTopology); + loadBtn.addEventListener("click", async () => { + await loadTopology(); + await loadDeviceTable(scanIdInput.value.trim()); + }); + refreshDevicesBtn.addEventListener("click", () => loadDeviceTable(scanIdInput.value.trim())); createScanBtn.addEventListener("click", createScan); watchScanBtn.addEventListener("click", watchScan); scanSelect.addEventListener("change", () => {