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:
@@ -15,6 +15,7 @@
|
|||||||
- `GET /api/scans/{id}/lldp` - LLDP соседи (если устройство отдает LLDP-MIB по SNMP).
|
- `GET /api/scans/{id}/lldp` - LLDP соседи (если устройство отдает LLDP-MIB по SNMP).
|
||||||
- `GET /api/scans/{id}/links` - базовые связи из LLDP с попыткой сопоставления по `remote_sys_name`.
|
- `GET /api/scans/{id}/links` - базовые связи из LLDP с попыткой сопоставления по `remote_sys_name`.
|
||||||
- `GET /api/scans/{id}/topology` - граф `nodes/edges` для визуализации.
|
- `GET /api/scans/{id}/topology` - граф `nodes/edges` для визуализации.
|
||||||
|
- `GET /` - простая UI-страница для просмотра топологии.
|
||||||
- Валидация CIDR и exclude IP.
|
- Валидация CIDR и exclude IP.
|
||||||
- Два backend-хранилища: in-memory и PostgreSQL.
|
- Два backend-хранилища: in-memory и PostgreSQL.
|
||||||
- После создания scan запускается фоновый discovery с обновлением статуса и прогресса.
|
- После создания scan запускается фоновый discovery с обновлением статуса и прогресса.
|
||||||
@@ -49,6 +50,12 @@ cp .env.example .env
|
|||||||
HTTP_ADDR=:8080 go run ./cmd/server
|
HTTP_ADDR=:8080 go run ./cmd/server
|
||||||
```
|
```
|
||||||
|
|
||||||
|
UI открывается по адресу:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
http://localhost:8080/
|
||||||
|
```
|
||||||
|
|
||||||
По умолчанию используется `STORE_BACKEND=memory`.
|
По умолчанию используется `STORE_BACKEND=memory`.
|
||||||
SNMP-проверка по умолчанию включена (`SNMP_ENABLED=true`, community `public`).
|
SNMP-проверка по умолчанию включена (`SNMP_ENABLED=true`, community `public`).
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -85,7 +85,7 @@
|
|||||||
2. [x] PostgreSQL и миграции
|
2. [x] PostgreSQL и миграции
|
||||||
3. [x] Ping + port scan
|
3. [x] Ping + port scan
|
||||||
4. [x] SNMP system + LLDP
|
4. [x] SNMP system + LLDP
|
||||||
5. [ ] Граф топологии в UI
|
5. [x] Граф топологии в UI (минимальная web-страница `/`)
|
||||||
6. [x] История и diff
|
6. [x] История и diff
|
||||||
|
|
||||||
## Критерии приемки v1
|
## Критерии приемки v1
|
||||||
@@ -94,5 +94,5 @@
|
|||||||
- [x] живые узлы фиксируются в результатах;
|
- [x] живые узлы фиксируются в результатах;
|
||||||
- [x] SNMP-доступные устройства читаются;
|
- [x] SNMP-доступные устройства читаются;
|
||||||
- [x] LLDP-связи строятся минимум на тестовой сети;
|
- [x] LLDP-связи строятся минимум на тестовой сети;
|
||||||
- [ ] топология отображается в интерфейсе;
|
- [x] топология отображается в интерфейсе;
|
||||||
- [ ] приложение запускается на Linux и Windows (нужна ваша проверка на 2 ОС).
|
- [ ] приложение запускается на Linux и Windows (нужна ваша проверка на 2 ОС).
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ func NewHandler(store scans.Store, run *scans.Runner) *Handler {
|
|||||||
|
|
||||||
func (h *Handler) Routes() http.Handler {
|
func (h *Handler) Routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
webFS := http.FileServer(http.Dir("web"))
|
||||||
|
mux.Handle("GET /", webFS)
|
||||||
|
mux.Handle("GET /web/", http.StripPrefix("/web/", webFS))
|
||||||
mux.HandleFunc("GET /health", h.health)
|
mux.HandleFunc("GET /health", h.health)
|
||||||
mux.HandleFunc("POST /api/scans", h.createScan)
|
mux.HandleFunc("POST /api/scans", h.createScan)
|
||||||
mux.HandleFunc("GET /api/scans", h.listScans)
|
mux.HandleFunc("GET /api/scans", h.listScans)
|
||||||
@@ -411,7 +414,9 @@ func (h *Handler) getScanTopology(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func withJSON(next http.Handler) http.Handler {
|
func withJSON(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/health" {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+125
@@ -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>
|
||||||
Reference in New Issue
Block a user