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:
@@ -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`).
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user