Add minimal topology web UI and serve static files.

Provide browser-based topology viewer at root path and update technical spec checkboxes for completed UI milestone.

Made-with: Cursor
This commit is contained in:
Andrey Lutsenko
2026-04-09 22:07:15 +10:00
parent df32d43ac5
commit f1545b2afd
4 changed files with 140 additions and 3 deletions
+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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; }
input, 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; }
</style>
</head>
<body>
<h2>NetTopo: просмотр топологии</h2>
<div class="hint">Введите `scan_id`, нажмите "Загрузить", и страница покажет граф nodes/edges.</div>
<div class="row">
<input id="scanId" placeholder="scan_id" style="min-width: 380px;" />
<button id="loadBtn">Загрузить</button>
<button id="fitBtn">По центру</button>
</div>
<div id="status">Ожидание данных...</div>
<svg id="canvas" viewBox="0 0 1200 620"></svg>
<script>
const scanIdInput = document.getElementById("scanId");
const loadBtn = document.getElementById("loadBtn");
const fitBtn = document.getElementById("fitBtn");
const statusEl = document.getElementById("status");
const svg = document.getElementById("canvas");
const NS = "http://www.w3.org/2000/svg";
function setStatus(text) { statusEl.textContent = text; }
function clearSVG() {
while (svg.firstChild) svg.removeChild(svg.firstChild);
}
function drawTopology(data) {
clearSVG();
const nodes = data.nodes || [];
const edges = data.edges || [];
const byIP = new Map();
nodes.forEach(n => byIP.set(n.ip, n));
const centerX = 600;
const centerY = 310;
const radius = Math.max(160, Math.min(260, 60 + nodes.length * 8));
const pos = new Map();
nodes.forEach((n, i) => {
const a = (Math.PI * 2 * i) / Math.max(nodes.length, 1);
pos.set(n.ip, {
x: centerX + radius * Math.cos(a),
y: centerY + radius * Math.sin(a),
});
});
edges.forEach(e => {
const s = pos.get(e.source_ip);
if (!s) return;
const t = pos.get(e.target_ip) || { x: s.x + 30, y: s.y + 30 };
const line = document.createElementNS(NS, "line");
line.setAttribute("x1", String(s.x));
line.setAttribute("y1", String(s.y));
line.setAttribute("x2", String(t.x));
line.setAttribute("y2", String(t.y));
line.setAttribute("stroke", "#94a3b8");
line.setAttribute("stroke-width", "1.5");
svg.appendChild(line);
});
nodes.forEach(n => {
const p = pos.get(n.ip);
if (!p) return;
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("opacity", "0.9");
svg.appendChild(circle);
const txt = document.createElementNS(NS, "text");
txt.setAttribute("x", String(p.x + 22));
txt.setAttribute("y", String(p.y + 5));
txt.setAttribute("font-size", "12");
txt.setAttribute("fill", "#111827");
txt.textContent = n.name ? `${n.name} (${n.ip})` : n.ip;
svg.appendChild(txt);
});
}
async function loadTopology() {
const id = scanIdInput.value.trim();
if (!id) {
setStatus("Введите scan_id");
return;
}
setStatus("Загружаю topology...");
try {
const res = await fetch(`/api/scans/${encodeURIComponent(id)}/topology?limit=5000`);
if (!res.ok) {
const t = await res.text();
setStatus(`Ошибка ${res.status}: ${t}`);
return;
}
const data = await res.json();
drawTopology(data);
setStatus(`Готово. Узлов: ${data.nodes?.length || 0}, связей: ${data.edges?.length || 0}`);
} catch (e) {
setStatus(`Ошибка загрузки: ${e.message}`);
}
}
loadBtn.addEventListener("click", loadTopology);
fitBtn.addEventListener("click", () => {
svg.setAttribute("viewBox", "0 0 1200 620");
});
</script>
</body>
</html>