UI: explain scan pipeline, live status line, device table; README scan logic.
Made-with: Cursor
This commit is contained in:
@@ -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)
|
||||
|
||||
+185
-35
@@ -6,20 +6,27 @@
|
||||
<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; }
|
||||
.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: #374151; }
|
||||
#status { margin: 8px 0; color: #1e40af; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 10px 12px; font-size: 14px; line-height: 1.45; }
|
||||
#canvas { border: 1px solid #d1d5db; border-radius: 8px; width: 100%; height: 620px; }
|
||||
.hint { color: #6b7280; font-size: 13px; margin-bottom: 8px; }
|
||||
.pipeline { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; margin-bottom: 14px; font-size: 13px; line-height: 1.5; color: #334155; }
|
||||
.pipeline strong { color: #0f172a; }
|
||||
.panel { margin: 14px 0; }
|
||||
#deviceTable th, #deviceTable td { border-bottom: 1px solid #e5e7eb; padding: 6px 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>NetTopo: просмотр топологии</h2>
|
||||
<div class="hint">Можно выбрать последний scan автоматически или сравнить два скана через встроенный diff.</div>
|
||||
<div class="hint">После скана нажмите «Загрузить», чтобы нарисовать граф по данным LLDP/SNMP.</div>
|
||||
<div class="pipeline">
|
||||
<strong>Один scan = один полный проход по сети.</strong> Для каждого IP из CIDR (с исключениями): <strong>ping</strong> → для ответивших — проверка <strong>TCP-портов</strong> → если включён SNMP — опрос <strong>системы (sysName и др.)</strong> и сразу обход <strong>LLDP-MIB</strong> (соседи по портам). Отдельной кнопки «только LLDP» нет: соседи попадают в этот же цикл. Кнопка «Создать scan» ставит задачу и сама ждёт завершения, показывая прогресс в строке статуса ниже.
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="cidrInput" placeholder="CIDR, например 192.168.1.0/24" style="min-width: 320px;" />
|
||||
<button id="createScanBtn">Создать scan</button>
|
||||
<button id="watchScanBtn">Ждать завершения</button>
|
||||
<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;" />
|
||||
@@ -37,7 +44,31 @@
|
||||
<button id="diffBtn">Сравнить</button>
|
||||
<button id="exportDiffBtn">Экспорт diff JSON</button>
|
||||
</div>
|
||||
<div id="status">Ожидание данных...</div>
|
||||
<div id="status">Ожидание действия. Строка статуса показывает текущую операцию и прогресс скана.</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="row" style="margin-bottom: 6px;">
|
||||
<strong>Найденные устройства</strong>
|
||||
<span class="hint" style="margin:0;">отвечают на ping; имя — из SNMP при успехе. Связи между устройствами — в графе («Загрузить»).</span>
|
||||
<button type="button" id="refreshDevicesBtn">Обновить список</button>
|
||||
</div>
|
||||
<div style="overflow:auto; max-height: 240px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||
<table id="deviceTable" style="width:100%; border-collapse: collapse; font-size: 13px;">
|
||||
<thead>
|
||||
<tr style="background:#f1f5f9;">
|
||||
<th style="text-align:left;">IP</th>
|
||||
<th style="text-align:left;">Ping</th>
|
||||
<th style="text-align:left;">Имя (SNMP)</th>
|
||||
<th style="text-align:left;">SNMP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="deviceTableBody">
|
||||
<tr><td colspan="4" style="padding:10px;color:#64748b;">Укажите scan_id или создайте scan.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin: 6px 0 10px 0; font-size: 13px;">
|
||||
Легенда:
|
||||
<span style="display:inline-block;margin-left:8px;color:#2563eb;">● синий - обычный узел</span>
|
||||
@@ -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 = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Нет scan_id.</td></tr>';
|
||||
return;
|
||||
}
|
||||
deviceTableBody.innerHTML = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
|
||||
try {
|
||||
const [hRes, sRes] = await Promise.all([
|
||||
fetch(`/api/scans/${encodeURIComponent(scanId)}/hosts?limit=5000`),
|
||||
fetch(`/api/scans/${encodeURIComponent(scanId)}/snmp?limit=5000`),
|
||||
]);
|
||||
if (!hRes.ok) throw new Error(await hRes.text());
|
||||
if (!sRes.ok) throw new Error(await sRes.text());
|
||||
const hData = await hRes.json();
|
||||
const sData = await sRes.json();
|
||||
const snmpByIp = new Map();
|
||||
for (const r of sData.items || []) {
|
||||
snmpByIp.set(r.ip, r);
|
||||
}
|
||||
const rows = [];
|
||||
for (const h of hData.items || []) {
|
||||
if (!h.is_up) continue;
|
||||
const s = snmpByIp.get(h.ip);
|
||||
const name = s && s.success ? (s.sys_name || "(пусто)") : "—";
|
||||
const snmpCell = s ? (s.success ? "OK" : (s.error || "нет ответа")) : "не опрашивался";
|
||||
rows.push(`<tr><td>${escapeHtml(h.ip)}</td><td>да</td><td>${escapeHtml(name)}</td><td>${escapeHtml(snmpCell)}</td></tr>`);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
deviceTableBody.innerHTML = '<tr><td colspan="4" style="padding:10px;color:#64748b;">Нет хостов с ping «вверх» для этого scan (или скан ещё не дошёл до них).</td></tr>';
|
||||
return;
|
||||
}
|
||||
deviceTableBody.innerHTML = rows.join("");
|
||||
} catch (e) {
|
||||
deviceTableBody.innerHTML = `<tr><td colspan="4" style="padding:10px;color:#b91c1c;">Ошибка: ${escapeHtml(e.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
function setDiff(text) { diffBox.textContent = text; }
|
||||
function downloadJSON(filename, data) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
@@ -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") {
|
||||
await loadTopology();
|
||||
setStatus(`Подключаюсь к задаче ${id}, буду опрашивать статус…`);
|
||||
await pollScanUntilDone(id, {
|
||||
intervalMs: 1500,
|
||||
onDone: async (job) => {
|
||||
await loadScanOptions();
|
||||
return;
|
||||
setStatus(describeScanProgress(job));
|
||||
if (job.status === "done") {
|
||||
await loadDeviceTable(id);
|
||||
await loadTopology();
|
||||
} 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", () => {
|
||||
|
||||
Reference in New Issue
Block a user