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);
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user