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`;
|
- загрузить topology по `scan_id`;
|
||||||
- автоматически подставить последний scan (`Последний scan`);
|
- автоматически подставить последний scan (`Последний scan`);
|
||||||
- сравнить два scan прямо на странице (`from`/`to`) через `/api/scans/diff`.
|
- сравнить два scan прямо на странице (`from`/`to`) через `/api/scans/diff`.
|
||||||
|
- выбрать scan из выпадающего списка последних 20;
|
||||||
|
- подсветить на графе новые (`[+]`, зеленый) и пропавшие (`[-]`, красный) хосты по diff.
|
||||||
|
|
||||||
По умолчанию используется `STORE_BACKEND=memory`.
|
По умолчанию используется `STORE_BACKEND=memory`.
|
||||||
SNMP-проверка по умолчанию включена (`SNMP_ENABLED=true`, community `public`).
|
SNMP-проверка по умолчанию включена (`SNMP_ENABLED=true`, community `public`).
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
- [x] добавлены endpoints результатов: hosts, ports, snmp, lldp, links, topology;
|
- [x] добавлены endpoints результатов: hosts, ports, snmp, lldp, links, topology;
|
||||||
- [x] подготовлены конфиги для запуска как сервис на Linux и за IIS на Windows.
|
- [x] подготовлены конфиги для запуска как сервис на Linux и за IIS на Windows.
|
||||||
- [x] UI позволяет выбрать последний scan и выполнить diff двух сканов.
|
- [x] UI позволяет выбрать последний scan и выполнить diff двух сканов.
|
||||||
|
- [x] UI позволяет выбирать scan из списка и подсвечивать изменения на графе.
|
||||||
|
|
||||||
## Минимальные API
|
## Минимальные API
|
||||||
|
|
||||||
|
|||||||
+64
-3
@@ -7,7 +7,7 @@
|
|||||||
<style>
|
<style>
|
||||||
body { font-family: Arial, sans-serif; margin: 16px; color: #1f2937; }
|
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; }
|
||||||
input, button { padding: 8px 10px; font-size: 14px; }
|
input, select, button { padding: 8px 10px; font-size: 14px; }
|
||||||
#status { margin: 8px 0; color: #374151; }
|
#status { margin: 8px 0; color: #374151; }
|
||||||
#canvas { border: 1px solid #d1d5db; border-radius: 8px; width: 100%; height: 620px; }
|
#canvas { border: 1px solid #d1d5db; border-radius: 8px; width: 100%; height: 620px; }
|
||||||
.hint { color: #6b7280; font-size: 13px; margin-bottom: 8px; }
|
.hint { color: #6b7280; font-size: 13px; margin-bottom: 8px; }
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<div class="hint">Можно выбрать последний scan автоматически или сравнить два скана через встроенный diff.</div>
|
<div class="hint">Можно выбрать последний scan автоматически или сравнить два скана через встроенный diff.</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input id="scanId" placeholder="scan_id" style="min-width: 380px;" />
|
<input id="scanId" placeholder="scan_id" style="min-width: 380px;" />
|
||||||
|
<select id="scanSelect" style="min-width: 360px;"></select>
|
||||||
<button id="loadBtn">Загрузить</button>
|
<button id="loadBtn">Загрузить</button>
|
||||||
<button id="latestBtn">Последний scan</button>
|
<button id="latestBtn">Последний scan</button>
|
||||||
<button id="fitBtn">По центру</button>
|
<button id="fitBtn">По центру</button>
|
||||||
@@ -25,6 +26,8 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<input id="fromScanId" placeholder="from scan_id" style="min-width: 280px;" />
|
<input id="fromScanId" placeholder="from scan_id" style="min-width: 280px;" />
|
||||||
<input id="toScanId" placeholder="to 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>
|
<button id="diffBtn">Сравнить</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="status">Ожидание данных...</div>
|
<div id="status">Ожидание данных...</div>
|
||||||
@@ -33,8 +36,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const scanIdInput = document.getElementById("scanId");
|
const scanIdInput = document.getElementById("scanId");
|
||||||
|
const scanSelect = document.getElementById("scanSelect");
|
||||||
const fromScanIdInput = document.getElementById("fromScanId");
|
const fromScanIdInput = document.getElementById("fromScanId");
|
||||||
const toScanIdInput = document.getElementById("toScanId");
|
const toScanIdInput = document.getElementById("toScanId");
|
||||||
|
const fromScanSelect = document.getElementById("fromScanSelect");
|
||||||
|
const toScanSelect = document.getElementById("toScanSelect");
|
||||||
const loadBtn = document.getElementById("loadBtn");
|
const loadBtn = document.getElementById("loadBtn");
|
||||||
const latestBtn = document.getElementById("latestBtn");
|
const latestBtn = document.getElementById("latestBtn");
|
||||||
const diffBtn = document.getElementById("diffBtn");
|
const diffBtn = document.getElementById("diffBtn");
|
||||||
@@ -51,7 +57,7 @@
|
|||||||
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawTopology(data) {
|
function drawTopology(data, highlight = null) {
|
||||||
clearSVG();
|
clearSVG();
|
||||||
const nodes = data.nodes || [];
|
const nodes = data.nodes || [];
|
||||||
const edges = data.edges || [];
|
const edges = data.edges || [];
|
||||||
@@ -88,12 +94,14 @@
|
|||||||
nodes.forEach(n => {
|
nodes.forEach(n => {
|
||||||
const p = pos.get(n.ip);
|
const p = pos.get(n.ip);
|
||||||
if (!p) return;
|
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");
|
const circle = document.createElementNS(NS, "circle");
|
||||||
circle.setAttribute("cx", String(p.x));
|
circle.setAttribute("cx", String(p.x));
|
||||||
circle.setAttribute("cy", String(p.y));
|
circle.setAttribute("cy", String(p.y));
|
||||||
circle.setAttribute("r", "16");
|
circle.setAttribute("r", "16");
|
||||||
circle.setAttribute("fill", "#2563eb");
|
circle.setAttribute("fill", isNew ? "#16a34a" : (isMissing ? "#dc2626" : "#2563eb"));
|
||||||
circle.setAttribute("opacity", "0.9");
|
circle.setAttribute("opacity", "0.9");
|
||||||
svg.appendChild(circle);
|
svg.appendChild(circle);
|
||||||
|
|
||||||
@@ -103,10 +111,45 @@
|
|||||||
txt.setAttribute("font-size", "12");
|
txt.setAttribute("font-size", "12");
|
||||||
txt.setAttribute("fill", "#111827");
|
txt.setAttribute("fill", "#111827");
|
||||||
txt.textContent = n.name ? `${n.name} (${n.ip})` : n.ip;
|
txt.textContent = n.name ? `${n.name} (${n.ip})` : n.ip;
|
||||||
|
if (isNew) txt.textContent += " [+]";
|
||||||
|
if (isMissing) txt.textContent += " [-]";
|
||||||
svg.appendChild(txt);
|
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() {
|
async function loadTopology() {
|
||||||
const id = scanIdInput.value.trim();
|
const id = scanIdInput.value.trim();
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -170,6 +213,14 @@
|
|||||||
}
|
}
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
setDiff(JSON.stringify(d, null, 2));
|
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(
|
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}`
|
`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);
|
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);
|
latestBtn.addEventListener("click", loadLatestScan);
|
||||||
diffBtn.addEventListener("click", loadDiff);
|
diffBtn.addEventListener("click", loadDiff);
|
||||||
fitBtn.addEventListener("click", () => {
|
fitBtn.addEventListener("click", () => {
|
||||||
svg.setAttribute("viewBox", "0 0 1200 620");
|
svg.setAttribute("viewBox", "0 0 1200 620");
|
||||||
});
|
});
|
||||||
setDiff("Diff будет показан здесь...");
|
setDiff("Diff будет показан здесь...");
|
||||||
|
loadScanOptions();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user