feat: initial commit

This commit is contained in:
nikola
2026-05-19 14:53:36 +02:00
commit 7d34caf1a2
19 changed files with 1114 additions and 0 deletions
+205
View File
@@ -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);
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Network Topology Wrapper</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<header>
<div>
<h1>Network Topology Live View</h1>
<p>Internal DevOps topology, discovery, and device presence</p>
</div>
<div class="meta">
<span id="lastUpdated">Waiting for data...</span>
</div>
</header>
<main>
<section class="panel graph-panel">
<div class="panel-title-row">
<h2>Topology</h2>
<button id="refreshBtn">Refresh</button>
</div>
<svg id="graph" viewBox="0 0 1200 700" preserveAspectRatio="xMidYMid meet"></svg>
</section>
<section class="panel devices-panel">
<h2>Devices</h2>
<div class="table-wrap">
<table id="deviceTable">
<thead>
<tr>
<th>Status</th>
<th>IP</th>
<th>Name</th>
<th>Type</th>
<th>Vendor</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section class="panel events-panel">
<h2>Live Events</h2>
<ul id="eventList"></ul>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>
+191
View File
@@ -0,0 +1,191 @@
:root {
--bg: #f5f8fb;
--panel: #ffffff;
--ink: #0b1f33;
--muted: #516173;
--line: #d2d9e2;
--ok: #16a34a;
--warn: #f59e0b;
--off: #94a3b8;
--router: #0ea5e9;
--switch: #f97316;
--ap: #22c55e;
--client: #6366f1;
--domain: #64748b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
color: var(--ink);
background: radial-gradient(circle at top right, #eaf4ff, var(--bg) 55%);
}
header {
display: flex;
justify-content: space-between;
align-items: end;
gap: 1rem;
padding: 1.2rem 1.4rem;
border-bottom: 1px solid var(--line);
}
h1 {
margin: 0;
font-size: 1.35rem;
}
p {
margin: 0.25rem 0 0;
color: var(--muted);
}
.meta {
color: var(--muted);
font-size: 0.9rem;
}
main {
display: grid;
gap: 1rem;
padding: 1rem;
grid-template-columns: 2fr 1.2fr;
grid-template-areas:
"graph devices"
"graph events";
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.06);
padding: 0.8rem;
}
.graph-panel {
grid-area: graph;
}
.devices-panel {
grid-area: devices;
}
.events-panel {
grid-area: events;
}
.panel h2 {
margin: 0.2rem 0 0.6rem;
font-size: 1rem;
}
.panel-title-row {
display: flex;
justify-content: space-between;
align-items: center;
}
button {
border: 1px solid var(--line);
background: #f8fafc;
border-radius: 8px;
padding: 0.35rem 0.6rem;
cursor: pointer;
}
#graph {
width: 100%;
height: 640px;
border: 1px dashed var(--line);
border-radius: 10px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.table-wrap {
max-height: 310px;
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
text-align: left;
border-bottom: 1px solid var(--line);
padding: 0.35rem;
}
.status-pill {
display: inline-block;
border-radius: 999px;
padding: 0.15rem 0.55rem;
font-size: 0.78rem;
color: #fff;
}
.status-online {
background: var(--ok);
}
.status-offline {
background: var(--off);
}
#eventList {
list-style: none;
margin: 0;
padding: 0;
max-height: 260px;
overflow: auto;
font-size: 0.9rem;
}
#eventList li {
border-bottom: 1px solid var(--line);
padding: 0.42rem 0;
color: var(--muted);
}
.node-label {
font-size: 12px;
font-weight: 600;
fill: #0f172a;
}
.node {
stroke: #1e293b;
stroke-width: 1.2;
}
.edge {
stroke: #94a3b8;
stroke-width: 1.2;
}
.edge-dns {
stroke-dasharray: 4 3;
opacity: 0.65;
}
@media (max-width: 1200px) {
main {
grid-template-columns: 1fr;
grid-template-areas:
"graph"
"devices"
"events";
}
#graph {
height: 520px;
}
}