Files
nettopo-go/internal/webui/index.html
T
Луценко Андрей Анатольевич 659b76f1cf Embed UI in binary (fix 404 when web/ missing from WorkingDirectory). Document logs and troubleshooting.
Made-with: Cursor
2026-04-10 12:28:53 +10:00

406 lines
16 KiB
HTML

<!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, 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; }
</style>
</head>
<body>
<h2>NetTopo: просмотр топологии</h2>
<div class="hint">Можно выбрать последний scan автоматически или сравнить два скана через встроенный diff.</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>
</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="exportTopologyBtn">Экспорт topology JSON</button>
<button id="fitBtn">По центру</button>
</div>
<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>
<button id="exportDiffBtn">Экспорт diff JSON</button>
</div>
<div id="status">Ожидание данных...</div>
<div style="margin: 6px 0 10px 0; font-size: 13px;">
Легенда:
<span style="display:inline-block;margin-left:8px;color:#2563eb;">● синий - обычный узел</span>
<span style="display:inline-block;margin-left:8px;color:#16a34a;">● зеленый - новый</span>
<span style="display:inline-block;margin-left:8px;color:#dc2626;">● красный - пропавший</span>
<span style="display:inline-block;margin-left:12px;">Фильтр:</span>
<select id="nodeFilter">
<option value="all">все</option>
<option value="new">только новые</option>
<option value="missing">только пропавшие</option>
</select>
</div>
<pre id="diffBox" style="background:#f8fafc;border:1px solid #e5e7eb;border-radius:8px;padding:10px;white-space:pre-wrap;"></pre>
<svg id="canvas" viewBox="0 0 1200 620"></svg>
<script>
const scanIdInput = document.getElementById("scanId");
const cidrInput = document.getElementById("cidrInput");
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 createScanBtn = document.getElementById("createScanBtn");
const watchScanBtn = document.getElementById("watchScanBtn");
const latestBtn = document.getElementById("latestBtn");
const diffBtn = document.getElementById("diffBtn");
const exportTopologyBtn = document.getElementById("exportTopologyBtn");
const exportDiffBtn = document.getElementById("exportDiffBtn");
const fitBtn = document.getElementById("fitBtn");
const nodeFilter = document.getElementById("nodeFilter");
const statusEl = document.getElementById("status");
const diffBox = document.getElementById("diffBox");
const svg = document.getElementById("canvas");
const NS = "http://www.w3.org/2000/svg";
let lastTopology = null;
let lastHighlight = null;
let lastDiff = null;
function setStatus(text) { statusEl.textContent = text; }
function setDiff(text) { diffBox.textContent = text; }
function downloadJSON(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function clearSVG() {
while (svg.firstChild) svg.removeChild(svg.firstChild);
}
function drawTopology(data, highlight = null) {
clearSVG();
let nodes = data.nodes || [];
const edges = data.edges || [];
const filter = nodeFilter.value || "all";
if (highlight && (filter === "new" || filter === "missing")) {
nodes = nodes.filter(n => {
const isNew = highlight.newHosts && highlight.newHosts.has(n.ip);
const isMissing = highlight.missingHosts && highlight.missingHosts.has(n.ip);
return filter === "new" ? isNew : isMissing;
});
}
const visibleIPs = new Set(nodes.map(n => n.ip));
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;
if (!visibleIPs.has(e.source_ip)) return;
if (e.target_ip && !visibleIPs.has(e.target_ip)) 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 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", isNew ? "#16a34a" : (isMissing ? "#dc2626" : "#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;
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) {
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();
lastTopology = data;
lastHighlight = null;
drawTopology(data, null);
setStatus(`Готово. Узлов: ${data.nodes?.length || 0}, связей: ${data.edges?.length || 0}`);
} catch (e) {
setStatus(`Ошибка загрузки: ${e.message}`);
}
}
async function createScan() {
const cidr = cidrInput.value.trim();
if (!cidr) {
setStatus("Введите CIDR для создания scan");
return;
}
setStatus("Создаю scan...");
try {
const payload = {
name: `manual-${new Date().toISOString()}`,
cidrs: [cidr],
exclude_ips: [],
options: {
ping_timeout_ms: 700,
ping_retries: 1,
max_parallel_hosts: 128,
port_scan_enabled: true,
ports: [22, 80, 443, 161]
}
};
const res = await fetch("/api/scans", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) {
const t = await res.text();
setStatus(`Ошибка создания scan: ${res.status} ${t}`);
return;
}
const d = await res.json();
scanIdInput.value = d.scan_id;
toScanIdInput.value = d.scan_id;
setStatus(`Scan создан: ${d.scan_id}`);
await loadScanOptions();
} catch (e) {
setStatus(`Ошибка создания scan: ${e.message}`);
}
}
async function watchScan() {
const id = scanIdInput.value.trim();
if (!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();
await loadScanOptions();
return;
}
} catch (e) {
setStatus(`Ошибка ожидания scan: ${e.message}`);
return;
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
setStatus("Таймаут ожидания scan");
}
async function loadLatestScan() {
setStatus("Ищу последний scan...");
try {
const res = await fetch("/api/scans?limit=1");
if (!res.ok) {
setStatus(`Ошибка ${res.status} при получении scan list`);
return;
}
const data = await res.json();
const latest = data.items && data.items[0];
if (!latest || !latest.id) {
setStatus("Сканы не найдены");
return;
}
scanIdInput.value = latest.id;
toScanIdInput.value = latest.id;
setStatus(`Найден последний scan: ${latest.id}`);
await loadTopology();
} catch (e) {
setStatus(`Ошибка загрузки последнего scan: ${e.message}`);
}
}
async function loadDiff() {
const fromId = fromScanIdInput.value.trim();
const toId = toScanIdInput.value.trim();
if (!fromId || !toId) {
setStatus("Введите from и to scan_id");
return;
}
setStatus("Сравниваю сканы...");
try {
const url = `/api/scans/diff?from=${encodeURIComponent(fromId)}&to=${encodeURIComponent(toId)}`;
const res = await fetch(url);
if (!res.ok) {
const t = await res.text();
setStatus(`Ошибка ${res.status}: ${t}`);
return;
}
const d = await res.json();
lastDiff = d;
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();
const highlight = {
newHosts: new Set(d.new_hosts || []),
missingHosts: new Set(d.missing_hosts || []),
};
lastTopology = topo;
lastHighlight = highlight;
drawTopology(topo, highlight);
}
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}`
);
} catch (e) {
setStatus(`Ошибка diff: ${e.message}`);
}
}
loadBtn.addEventListener("click", loadTopology);
createScanBtn.addEventListener("click", createScan);
watchScanBtn.addEventListener("click", watchScan);
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);
exportTopologyBtn.addEventListener("click", () => {
const scanId = scanIdInput.value.trim() || "unknown";
if (!lastTopology) {
setStatus("Сначала загрузите topology");
return;
}
downloadJSON(`topology_${scanId}.json`, lastTopology);
setStatus("Topology JSON экспортирован");
});
exportDiffBtn.addEventListener("click", () => {
const fromId = fromScanIdInput.value.trim() || "from";
const toId = toScanIdInput.value.trim() || "to";
if (!lastDiff) {
setStatus("Сначала выполните diff");
return;
}
downloadJSON(`diff_${fromId}_to_${toId}.json`, lastDiff);
setStatus("Diff JSON экспортирован");
});
fitBtn.addEventListener("click", () => {
svg.setAttribute("viewBox", "0 0 1200 620");
});
nodeFilter.addEventListener("change", () => {
if (lastTopology) {
drawTopology(lastTopology, lastHighlight);
}
});
setDiff("Diff будет показан здесь...");
loadScanOptions();
</script>
</body>
</html>