Files
network-topology-wrapper/frontend/app.js
T
2026-05-19 14:53:36 +02:00

206 lines
5.9 KiB
JavaScript

const graphEl = document.getElementById("graph");
const deviceTableBody = document.querySelector("#deviceTable tbody");
const eventList = document.getElementById("eventList");
const refreshBtn = document.getElementById("refreshBtn");
const lastUpdated = document.getElementById("lastUpdated");
const NODE_RADIUS = 17;
function kindColor(kind, status) {
if (status === "offline") return "#94a3b8";
if (kind === "router") return "#0ea5e9";
if (kind === "switch") return "#f97316";
if (kind === "ap") return "#22c55e";
if (kind === "domain") return "#64748b";
return "#6366f1";
}
function safe(v) {
return v == null ? "" : String(v);
}
function formatTs(ts) {
if (!ts) return "-";
return new Date(ts).toLocaleString();
}
function renderGraph(data) {
const width = 1200;
const height = 700;
graphEl.innerHTML = "";
const nodes = data.nodes || [];
const edges = data.edges || [];
const byId = new Map(nodes.map((n) => [n.id, n]));
const router = nodes.find((n) => n.kind === "router");
const sw = nodes.find((n) => n.kind === "switch");
const aps = nodes.filter((n) => n.kind === "ap");
const domains = nodes.filter((n) => n.kind === "domain");
const clients = nodes.filter(
(n) => !["router", "switch", "ap", "domain"].includes(n.kind)
);
const pos = new Map();
if (router) pos.set(router.id, { x: 180, y: 120 });
if (sw) pos.set(sw.id, { x: 420, y: 120 });
aps.forEach((n, i) => {
pos.set(n.id, { x: 240 + i * 160, y: 280 });
});
const cols = 5;
clients.forEach((n, i) => {
const row = Math.floor(i / cols);
const col = i % cols;
pos.set(n.id, { x: 120 + col * 180, y: 470 + row * 120 });
});
domains.forEach((n, i) => {
pos.set(n.id, { x: 900, y: 120 + i * 70 });
});
nodes.forEach((n, i) => {
if (!pos.has(n.id)) {
pos.set(n.id, { x: 860 + (i % 3) * 80, y: 420 + Math.floor(i / 3) * 80 });
}
});
const ns = "http://www.w3.org/2000/svg";
edges.forEach((e) => {
const s = pos.get(e.source);
const t = pos.get(e.target);
if (!s || !t) return;
const line = document.createElementNS(ns, "line");
line.setAttribute("x1", s.x);
line.setAttribute("y1", s.y);
line.setAttribute("x2", t.x);
line.setAttribute("y2", t.y);
line.setAttribute("class", `edge ${e.kind === "dns" ? "edge-dns" : ""}`);
graphEl.appendChild(line);
});
nodes.forEach((n) => {
const p = pos.get(n.id);
if (!p) return;
const circle = document.createElementNS(ns, "circle");
circle.setAttribute("cx", p.x);
circle.setAttribute("cy", p.y);
circle.setAttribute("r", n.kind === "domain" ? NODE_RADIUS - 4 : NODE_RADIUS);
circle.setAttribute("fill", kindColor(n.kind, n.status));
circle.setAttribute("class", "node");
circle.setAttribute(
"title",
`${safe(n.label)}\n${safe(n.ip)}\n${safe(n.kind)}\n${safe(n.status)}`
);
graphEl.appendChild(circle);
const text = document.createElementNS(ns, "text");
text.setAttribute("x", p.x + 24);
text.setAttribute("y", p.y + 4);
text.setAttribute("class", "node-label");
text.textContent = `${safe(n.label)}${n.ip ? ` (${n.ip})` : ""}`;
graphEl.appendChild(text);
});
const frame = document.createElementNS(ns, "rect");
frame.setAttribute("x", 1);
frame.setAttribute("y", 1);
frame.setAttribute("width", width - 2);
frame.setAttribute("height", height - 2);
frame.setAttribute("fill", "none");
frame.setAttribute("stroke", "#e2e8f0");
frame.setAttribute("rx", "10");
graphEl.insertBefore(frame, graphEl.firstChild);
}
function renderTable(devices) {
deviceTableBody.innerHTML = "";
devices
.slice()
.sort((a, b) => String(a.ip).localeCompare(String(b.ip), undefined, { numeric: true }))
.forEach((d) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><span class="status-pill status-${safe(d.status)}">${safe(d.status)}</span></td>
<td>${safe(d.ip)}</td>
<td>${safe(d.hostname || d.name || d.ip)}</td>
<td>${safe(d.type)}</td>
<td>${safe(d.vendor || "-")}</td>
<td>${formatTs(d.last_seen)}</td>
`;
deviceTableBody.appendChild(tr);
});
}
function addEventLine(text) {
const li = document.createElement("li");
li.textContent = `${new Date().toLocaleTimeString()} ${text}`;
eventList.prepend(li);
while (eventList.children.length > 120) {
eventList.removeChild(eventList.lastChild);
}
}
function eventToText(evt) {
if (evt.kind === "device_discovered") return `New device ${evt.ip}`;
if (evt.kind === "device_online") return `Device online ${evt.ip}`;
if (evt.kind === "device_offline") return `Device offline ${evt.ip}`;
if (evt.kind === "dns_query") return `${evt.ip} queried ${evt.qname}`;
if (evt.kind === "flow") {
const dst = evt.dst_host || evt.dst_ip || "unknown";
return `Flow ${evt.src_ip} -> ${dst}`;
}
return `${evt.kind}`;
}
async function loadTopology() {
const res = await fetch("/api/topology", { cache: "no-store" });
const data = await res.json();
renderGraph(data);
renderTable(data.devices || []);
lastUpdated.textContent = `Last update: ${new Date().toLocaleTimeString()}`;
}
function startEvents() {
const sse = new EventSource("/api/events/stream");
sse.onmessage = (msg) => {
try {
const evt = JSON.parse(msg.data);
addEventLine(eventToText(evt));
if (
evt.kind === "device_discovered" ||
evt.kind === "device_online" ||
evt.kind === "device_offline"
) {
loadTopology().catch(() => {});
}
} catch (e) {
addEventLine("Event parse error");
}
};
sse.onerror = () => {
addEventLine("SSE reconnecting...");
};
}
refreshBtn.addEventListener("click", () => {
loadTopology().catch((err) => addEventLine(`Refresh failed: ${err}`));
});
loadTopology()
.then(startEvents)
.catch((err) => {
addEventLine(`Initial load failed: ${err}`);
startEvents();
});
setInterval(() => {
loadTopology().catch(() => {});
}, 15000);