feat: initial commit
This commit is contained in:
+205
@@ -0,0 +1,205 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user