Improve UI with scan selectors and diff host highlighting.

Add scan dropdown selectors and highlight new/missing hosts directly on topology graph from diff results.

Made-with: Cursor
This commit is contained in:
Andrey Lutsenko
2026-04-09 22:11:13 +10:00
parent 975a45ffd6
commit 6f6b822f16
3 changed files with 67 additions and 3 deletions
+2
View File
@@ -61,6 +61,8 @@ http://localhost:8080/
- загрузить topology по `scan_id`;
- автоматически подставить последний scan (`Последний scan`);
- сравнить два scan прямо на странице (`from`/`to`) через `/api/scans/diff`.
- выбрать scan из выпадающего списка последних 20;
- подсветить на графе новые (`[+]`, зеленый) и пропавшие (`[-]`, красный) хосты по diff.
По умолчанию используется `STORE_BACKEND=memory`.
SNMP-проверка по умолчанию включена (`SNMP_ENABLED=true`, community `public`).
+1
View File
@@ -52,6 +52,7 @@
- [x] добавлены endpoints результатов: hosts, ports, snmp, lldp, links, topology;
- [x] подготовлены конфиги для запуска как сервис на Linux и за IIS на Windows.
- [x] UI позволяет выбрать последний scan и выполнить diff двух сканов.
- [x] UI позволяет выбирать scan из списка и подсвечивать изменения на графе.
## Минимальные API
+64 -3
View File
@@ -7,7 +7,7 @@
<style>
body { font-family: Arial, sans-serif; margin: 16px; color: #1f2937; }
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
input, button { padding: 8px 10px; font-size: 14px; }
input, select, button { padding: 8px 10px; font-size: 14px; }
#status { margin: 8px 0; color: #374151; }
#canvas { border: 1px solid #d1d5db; border-radius: 8px; width: 100%; height: 620px; }
.hint { color: #6b7280; font-size: 13px; margin-bottom: 8px; }
@@ -18,6 +18,7 @@
<div class="hint">Можно выбрать последний scan автоматически или сравнить два скана через встроенный diff.</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="fitBtn">По центру</button>
@@ -25,6 +26,8 @@
<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>
</div>
<div id="status">Ожидание данных...</div>
@@ -33,8 +36,11 @@
<script>
const scanIdInput = document.getElementById("scanId");
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 latestBtn = document.getElementById("latestBtn");
const diffBtn = document.getElementById("diffBtn");
@@ -51,7 +57,7 @@
while (svg.firstChild) svg.removeChild(svg.firstChild);
}
function drawTopology(data) {
function drawTopology(data, highlight = null) {
clearSVG();
const nodes = data.nodes || [];
const edges = data.edges || [];
@@ -88,12 +94,14 @@
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", "16");
circle.setAttribute("fill", "#2563eb");
circle.setAttribute("fill", isNew ? "#16a34a" : (isMissing ? "#dc2626" : "#2563eb"));
circle.setAttribute("opacity", "0.9");
svg.appendChild(circle);
@@ -103,10 +111,45 @@
txt.setAttribute("font-size", "12");
txt.setAttribute("fill", "#111827");
txt.textContent = n.name ? `${n.name} (${n.ip})` : n.ip;
if (isNew) txt.textContent += " [+]";
if (isMissing) txt.textContent += " [-]";
svg.appendChild(txt);
});
}
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) {
@@ -170,6 +213,14 @@
}
const d = await res.json();
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();
drawTopology(topo, {
newHosts: new Set(d.new_hosts || []),
missingHosts: new Set(d.missing_hosts || []),
});
}
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}`
);
@@ -179,12 +230,22 @@
}
loadBtn.addEventListener("click", loadTopology);
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);
fitBtn.addEventListener("click", () => {
svg.setAttribute("viewBox", "0 0 1200 620");
});
setDiff("Diff будет показан здесь...");
loadScanOptions();
</script>
</body>
</html>