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 при успехе. Связи между устройствами — в графе («Загрузить»).
+
+
+
+
+
+
+
IP
+
Ping
+
Имя (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(`