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
+11
View File
@@ -0,0 +1,11 @@
APP_HOST=0.0.0.0
APP_PORT=8088
SCAN_SUBNET=192.168.88.0/24
SCAN_INTERVAL_SECONDS=15
SCAN_TIMEOUT_SECONDS=0.35
SCAN_CONCURRENCY=120
DNS_LOG_PATH=
CORE_ROUTER_IP=192.168.88.1
CORE_ROUTER_NAME=RB4011
CORE_SWITCH_IP=192.168.88.2
CORE_SWITCH_NAME=CRS328-24P
+4
View File
@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.pyc
.env
+15
View File
@@ -0,0 +1,15 @@
# Project AGENTS
## Scope
- Build an internal-only network topology and live device presence tool for DevOps.
- Keep setup simple for local laptop test and later VM deployment.
## Stack
- Backend: FastAPI (Python 3.11+)
- Storage: in-memory for MVP (optional Redis/Postgres later)
- Frontend: lightweight static UI served by backend
## Local Rules
- Do not expose this app publicly.
- Prefer host networking in Linux when testing discovery against local LAN.
- Keep MikroTik and switch credentials out of git; use `.env`.
+48
View File
@@ -0,0 +1,48 @@
# Network Topology Wrapper (MVP)
Internal tool for local network discovery, device tracking, and a live topology view.
Designed for quick laptop testing and easy move to an internal VM.
## Features (MVP)
- Periodic subnet scan (device presence)
- Device inventory (IP, hostname, guessed type, vendor hint)
- Live topology graph in web UI
- Live online/offline feed (SSE)
- Optional DNS log ingest for "which IP queried which domain"
- Optional ingest endpoints for DHCP lease and flow events
## Architecture
- `backend/app/main.py`: FastAPI app and API routes
- `backend/app/state.py`: in-memory state manager and topology builder
- `backend/app/scanner.py`: subnet scanner and hostname/type detection
- `backend/app/dnslog.py`: optional dnsmasq/unbound log tailing
- `frontend/`: static HTML/CSS/JS UI
- `scripts/`: helper setup scripts
## Quick Start (Laptop)
1. Copy env file:
```bash
cp .env.example .env
```
2. Adjust `SCAN_SUBNET` to your LAN (example `192.168.88.0/24`).
3. Run with Docker (Linux recommended for host networking):
```bash
docker compose up --build
```
4. Open: `http://localhost:8088`
## API
- `GET /api/topology`: full topology graph and latest device info
- `GET /api/events/stream`: server-sent events for live updates
- `POST /api/ingest/dns`: push DNS query events
- `POST /api/ingest/dhcp`: push DHCP lease events
- `POST /api/ingest/flow`: push flow events (lightweight)
## Notes
- Accurate topology improves a lot with SNMP + LLDP/CDP enabled on MikroTik/Cisco devices.
- "IP -> domain" visibility depends on DNS logs and client behavior (DoH/DoT may bypass local DNS visibility).
## Next Steps (after MVP)
- Add Redis + Postgres for persistence
- Add SNMP LLDP topology poller (MikroTik/Cisco)
- Add auth (basic SSO/reverse proxy)
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
COPY backend/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY backend/app /app/app
COPY frontend /app/frontend
COPY .env /app/.env
EXPOSE 8088
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8088"]
View File
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import asyncio
import os
import re
from datetime import datetime, timezone
from .state import NetworkState
# dnsmasq style: "query[A] example.com from 192.168.88.10"
DNS_QUERY_RE = re.compile(r"query\[[^\]]+\]\s+(?P<qname>\S+)\s+from\s+(?P<src_ip>\d+\.\d+\.\d+\.\d+)")
async def tail_dns_log(state: NetworkState, path: str) -> None:
if not path:
return
if not os.path.isfile(path):
return
with open(path, "r", encoding="utf-8", errors="ignore") as f:
f.seek(0, os.SEEK_END)
while True:
line = f.readline()
if not line:
await asyncio.sleep(0.5)
continue
m = DNS_QUERY_RE.search(line)
if not m:
continue
src_ip = m.group("src_ip")
qname = m.group("qname")
await state.add_dns_event(src_ip=src_ip, qname=qname, answers=[], ts=datetime.now(timezone.utc))
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
import asyncio
import json
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from .dnslog import tail_dns_log
from .scanner import scan_subnet
from .schemas import DhcpIngestEvent, DnsIngestEvent, FlowIngestEvent
from .settings import settings
from .state import NetworkState
state = NetworkState()
async def scanner_loop() -> None:
while True:
active_ips = await scan_subnet(
state=state,
subnet_cidr=settings.scan_subnet,
timeout_seconds=settings.scan_timeout_seconds,
concurrency=settings.scan_concurrency,
)
await state.mark_offline_missing(active_ips)
await asyncio.sleep(settings.scan_interval_seconds)
@asynccontextmanager
async def lifespan(_: FastAPI):
tasks = [asyncio.create_task(scanner_loop(), name="scanner-loop")]
if settings.dns_log_path:
tasks.append(asyncio.create_task(tail_dns_log(state, settings.dns_log_path), name="dns-tail-loop"))
try:
yield
finally:
for task in tasks:
task.cancel()
for task in tasks:
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(title="Network Topology Wrapper", lifespan=lifespan)
static_dir = Path(__file__).resolve().parents[2] / "frontend"
app.mount("/static", StaticFiles(directory=static_dir), name="static")
@app.get("/")
def index() -> FileResponse:
return FileResponse(static_dir / "index.html")
@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/topology")
def api_topology() -> dict:
return state.topology_payload(
core_router_ip=settings.core_router_ip,
core_router_name=settings.core_router_name,
core_switch_ip=settings.core_switch_ip,
core_switch_name=settings.core_switch_name,
)
@app.post("/api/ingest/dns")
async def ingest_dns(event: DnsIngestEvent) -> dict[str, str]:
await state.add_dns_event(
src_ip=event.src_ip,
qname=event.qname,
answers=event.answers,
ts=event.ts,
)
return {"status": "accepted"}
@app.post("/api/ingest/dhcp")
async def ingest_dhcp(event: DhcpIngestEvent) -> dict[str, str]:
await state.upsert_device(
ip=event.ip,
payload={
"mac": event.mac,
"hostname": event.hostname,
"name": event.hostname or event.ip,
"status": "online" if event.state != "offline" else "offline",
"source": "dhcp",
"type": "client",
},
)
return {"status": "accepted"}
@app.post("/api/ingest/flow")
async def ingest_flow(event: FlowIngestEvent) -> dict[str, str]:
await state.add_flow_event(
src_ip=event.src_ip,
dst_ip=event.dst_ip,
dst_host=event.dst_host,
protocol=event.protocol,
bytes_count=event.bytes_count,
ts=event.ts,
)
return {"status": "accepted"}
@app.get("/api/events/stream")
async def events_stream() -> StreamingResponse:
queue = state.subscribe()
async def stream():
try:
while True:
event = await queue.get()
payload = json.dumps(event, ensure_ascii=True)
yield f"data: {payload}\n\n"
finally:
state.unsubscribe(queue)
return StreamingResponse(stream(), media_type="text/event-stream")
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import asyncio
import ipaddress
import socket
from typing import Any
from .state import NetworkState, utc_now
COMMON_PORTS = (22, 53, 80, 443, 8291)
VENDOR_HINTS = {
"mikrotik": "MikroTik",
"cisco": "Cisco",
"ubiquiti": "Ubiquiti",
"raspberry": "Raspberry Pi",
}
def guess_device_type(hostname: str | None, open_port: int | None) -> str:
if not hostname and open_port is None:
return "unknown"
hn = (hostname or "").lower()
if "rb4011" in hn or "router" in hn:
return "router"
if "crs" in hn or "switch" in hn or "slm" in hn:
return "switch"
if "cap" in hn or "ap" in hn or "wifi" in hn:
return "ap"
if open_port == 8291:
return "mikrotik"
if open_port == 53:
return "dns"
return "client"
def guess_vendor(hostname: str | None) -> str | None:
if not hostname:
return None
hn = hostname.lower()
for token, vendor in VENDOR_HINTS.items():
if token in hn:
return vendor
return None
async def probe_host(ip: str, timeout: float) -> tuple[bool, int | None]:
for port in COMMON_PORTS:
try:
conn = asyncio.open_connection(ip, port)
reader, writer = await asyncio.wait_for(conn, timeout=timeout)
writer.close()
await writer.wait_closed()
return True, port
except Exception:
continue
return False, None
def reverse_dns(ip: str) -> str | None:
try:
return socket.gethostbyaddr(ip)[0]
except Exception:
return None
async def scan_subnet(
state: NetworkState,
subnet_cidr: str,
timeout_seconds: float,
concurrency: int,
) -> set[str]:
network = ipaddress.ip_network(subnet_cidr, strict=False)
active: set[str] = set()
sem = asyncio.Semaphore(concurrency)
async def one(ip_obj: Any) -> None:
ip = str(ip_obj)
async with sem:
is_up, port = await probe_host(ip, timeout_seconds)
if not is_up:
return
hostname = reverse_dns(ip)
active.add(ip)
await state.upsert_device(
ip,
{
"hostname": hostname,
"name": hostname or ip,
"type": guess_device_type(hostname, port),
"vendor": guess_vendor(hostname),
"status": "online",
"last_seen": utc_now(),
"source": "scanner",
},
)
await asyncio.gather(*(one(ip) for ip in network.hosts()))
return active
+28
View File
@@ -0,0 +1,28 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
class DnsIngestEvent(BaseModel):
src_ip: str
qname: str
answers: list[str] = Field(default_factory=list)
ts: datetime | None = None
class DhcpIngestEvent(BaseModel):
ip: str
mac: str | None = None
hostname: str | None = None
state: Literal["online", "offline", "lease"] = "lease"
ts: datetime | None = None
class FlowIngestEvent(BaseModel):
src_ip: str
dst_ip: str | None = None
dst_host: str | None = None
protocol: str | None = None
bytes_count: int | None = None
ts: datetime | None = None
+23
View File
@@ -0,0 +1,23 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_host: str = "0.0.0.0"
app_port: int = 8088
scan_subnet: str = "192.168.88.0/24"
scan_interval_seconds: int = 15
scan_timeout_seconds: float = 0.35
scan_concurrency: int = 120
dns_log_path: str = ""
core_router_ip: str = "192.168.88.1"
core_router_name: str = "RB4011"
core_switch_ip: str = "192.168.88.2"
core_switch_name: str = "CRS328-24P"
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
settings = Settings()
+202
View File
@@ -0,0 +1,202 @@
from __future__ import annotations
import asyncio
from collections import deque
from datetime import datetime, timedelta, timezone
from typing import Any
def utc_now() -> datetime:
return datetime.now(timezone.utc)
class NetworkState:
def __init__(self) -> None:
self.devices: dict[str, dict[str, Any]] = {}
self.dns_by_ip: dict[str, deque[dict[str, Any]]] = {}
self.flows: deque[dict[str, Any]] = deque(maxlen=2000)
self.events: deque[dict[str, Any]] = deque(maxlen=2000)
self._subscribers: set[asyncio.Queue] = set()
async def publish(self, event: dict[str, Any]) -> None:
self.events.append(event)
stale: list[asyncio.Queue] = []
for q in self._subscribers:
try:
q.put_nowait(event)
except asyncio.QueueFull:
stale.append(q)
for q in stale:
self._subscribers.discard(q)
def subscribe(self) -> asyncio.Queue:
queue: asyncio.Queue = asyncio.Queue(maxsize=200)
self._subscribers.add(queue)
return queue
def unsubscribe(self, queue: asyncio.Queue) -> None:
self._subscribers.discard(queue)
async def upsert_device(self, ip: str, payload: dict[str, Any]) -> None:
now = utc_now()
prev = self.devices.get(ip)
merged = {
"ip": ip,
"name": payload.get("name") or (prev or {}).get("name") or ip,
"hostname": payload.get("hostname") or (prev or {}).get("hostname"),
"mac": payload.get("mac") or (prev or {}).get("mac"),
"vendor": payload.get("vendor") or (prev or {}).get("vendor"),
"type": payload.get("type") or (prev or {}).get("type") or "unknown",
"status": payload.get("status") or "online",
"last_seen": payload.get("last_seen") or now,
"first_seen": (prev or {}).get("first_seen") or now,
"source": payload.get("source") or (prev or {}).get("source") or "scanner",
}
self.devices[ip] = merged
if prev is None:
await self.publish({
"kind": "device_discovered",
"ip": ip,
"device": self._serialize_device(merged),
"ts": now.isoformat(),
})
elif prev.get("status") != "online":
await self.publish({
"kind": "device_online",
"ip": ip,
"device": self._serialize_device(merged),
"ts": now.isoformat(),
})
async def mark_offline_missing(self, active_ips: set[str]) -> None:
now = utc_now()
for ip, device in list(self.devices.items()):
if ip in active_ips:
continue
if device.get("status") == "offline":
continue
if now - self._ensure_dt(device.get("last_seen")) < timedelta(seconds=45):
continue
device["status"] = "offline"
await self.publish({
"kind": "device_offline",
"ip": ip,
"device": self._serialize_device(device),
"ts": now.isoformat(),
})
async def add_dns_event(self, src_ip: str, qname: str, answers: list[str], ts: datetime | None = None) -> None:
now = ts or utc_now()
item = {"qname": qname, "answers": answers, "ts": now}
bucket = self.dns_by_ip.setdefault(src_ip, deque(maxlen=200))
bucket.append(item)
await self.upsert_device(src_ip, {"status": "online", "last_seen": now, "source": "dns"})
await self.publish({
"kind": "dns_query",
"ip": src_ip,
"qname": qname,
"answers": answers,
"ts": now.isoformat(),
})
async def add_flow_event(
self,
src_ip: str,
dst_ip: str | None,
dst_host: str | None,
protocol: str | None,
bytes_count: int | None,
ts: datetime | None = None,
) -> None:
now = ts or utc_now()
flow = {
"src_ip": src_ip,
"dst_ip": dst_ip,
"dst_host": dst_host,
"protocol": protocol or "unknown",
"bytes_count": bytes_count or 0,
"ts": now,
}
self.flows.append(flow)
await self.upsert_device(src_ip, {"status": "online", "last_seen": now, "source": "flow"})
await self.publish({
"kind": "flow",
"src_ip": src_ip,
"dst_ip": dst_ip,
"dst_host": dst_host,
"protocol": protocol,
"bytes_count": bytes_count,
"ts": now.isoformat(),
})
def topology_payload(self, core_router_ip: str, core_router_name: str, core_switch_ip: str, core_switch_name: str) -> dict[str, Any]:
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
nodes.append({"id": "router", "label": core_router_name, "kind": "router", "ip": core_router_ip})
nodes.append({"id": "switch", "label": core_switch_name, "kind": "switch", "ip": core_switch_ip})
edges.append({"id": "e-router-switch", "source": "router", "target": "switch", "kind": "core"})
for ip, device in sorted(self.devices.items(), key=lambda x: tuple(int(p) for p in x[0].split("."))):
node_id = f"dev-{ip}"
label = device.get("hostname") or device.get("name") or ip
nodes.append({
"id": node_id,
"label": label,
"kind": device.get("type") or "unknown",
"status": device.get("status") or "offline",
"ip": ip,
"vendor": device.get("vendor"),
"last_seen": self._ensure_dt(device.get("last_seen")).isoformat(),
})
parent = "router" if ip == core_router_ip else "switch"
edges.append({"id": f"e-{parent}-{node_id}", "source": parent, "target": node_id, "kind": "lan"})
domain_nodes: dict[str, str] = {}
for ip, entries in self.dns_by_ip.items():
src_id = f"dev-{ip}"
if src_id not in {n["id"] for n in nodes}:
continue
for item in list(entries)[-8:]:
qname = item.get("qname", "")
if not qname:
continue
domain_id = domain_nodes.get(qname)
if domain_id is None:
domain_id = f"domain-{len(domain_nodes)+1}"
domain_nodes[qname] = domain_id
nodes.append({"id": domain_id, "label": qname, "kind": "domain"})
edges.append({
"id": f"e-{src_id}-{domain_id}",
"source": src_id,
"target": domain_id,
"kind": "dns",
})
recent_events = list(self.events)[-40:]
return {
"generated_at": utc_now().isoformat(),
"nodes": nodes,
"edges": edges,
"devices": [self._serialize_device(d) for d in self.devices.values()],
"recent_events": recent_events,
}
@staticmethod
def _ensure_dt(value: Any) -> datetime:
if isinstance(value, datetime):
return value
return utc_now()
@staticmethod
def _serialize_device(device: dict[str, Any]) -> dict[str, Any]:
out = dict(device)
if isinstance(out.get("last_seen"), datetime):
out["last_seen"] = out["last_seen"].isoformat()
if isinstance(out.get("first_seen"), datetime):
out["first_seen"] = out["first_seen"].isoformat()
return out
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
pydantic==2.11.7
pydantic-settings==2.11.0
+10
View File
@@ -0,0 +1,10 @@
services:
topology:
build:
context: .
dockerfile: backend/Dockerfile
container_name: network-topology-wrapper
network_mode: host
env_file:
- .env
restart: unless-stopped
+29
View File
@@ -0,0 +1,29 @@
# MikroTik Setup (RB4011 / CRS / CAP AX)
Use this to improve discovery quality and live monitoring.
## 1) Enable SNMP (for future LLDP/topology pollers)
Example (adjust community and allowed IP):
```routeros
/snmp set enabled=yes contact="DevOps" location="Office" trap-version=2
/snmp community add name=net-topology addresses=192.168.88.0/24 read-access=yes
```
## 2) Enable LLDP/CDP neighbor discovery
```routeros
/ip neighbor discovery-settings set discover-interface-list=all protocol=lldp
```
## 3) Optional: Send flow data (traffic relations)
RouterOS v7 supports Traffic Flow (NetFlow/IPFIX style):
```routeros
/ip traffic-flow set enabled=yes interfaces=all cache-entries=4k active-flow-timeout=30m inactive-flow-timeout=15s
/ip traffic-flow target add dst-address=192.168.88.50 port=2055 version=9
```
- Set `dst-address` to the VM/laptop running your collector/flow parser.
## 4) Optional DNS visibility
If your DHCP clients use local DNS server, send DNS logs/events to the collector endpoint:
- `POST http://<collector>:8088/api/ingest/dns`
For now in MVP, easiest path is log tailing from local DNS server logs (`DNS_LOG_PATH` in `.env`).
+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;
}
}
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
if [[ ! -d .venv ]]; then
python3 -m venv .venv
fi
source .venv/bin/activate
pip install -r backend/requirements.txt
uvicorn backend.app.main:app --host 0.0.0.0 --port 8088 --reload