From 7d34caf1a21189cf0b0b15f08d306b71471e59c4 Mon Sep 17 00:00:00 2001 From: nikola Date: Tue, 19 May 2026 14:53:36 +0200 Subject: [PATCH] feat: initial commit --- .env.example | 11 +++ .gitignore | 4 + AGENTS.md | 15 +++ README.md | 48 +++++++++ backend/Dockerfile | 14 +++ backend/app/__init__.py | 0 backend/app/dnslog.py | 33 +++++++ backend/app/main.py | 129 ++++++++++++++++++++++++ backend/app/scanner.py | 100 +++++++++++++++++++ backend/app/schemas.py | 28 ++++++ backend/app/settings.py | 23 +++++ backend/app/state.py | 202 ++++++++++++++++++++++++++++++++++++++ backend/requirements.txt | 4 + docker-compose.yml | 10 ++ docs/mikrotik-setup.md | 29 ++++++ frontend/app.js | 205 +++++++++++++++++++++++++++++++++++++++ frontend/index.html | 56 +++++++++++ frontend/styles.css | 191 ++++++++++++++++++++++++++++++++++++ scripts/run-local.sh | 12 +++ 19 files changed, 1114 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/dnslog.py create mode 100644 backend/app/main.py create mode 100644 backend/app/scanner.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/settings.py create mode 100644 backend/app/state.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 docs/mikrotik-setup.md create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/styles.css create mode 100755 scripts/run-local.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1e32263 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a98520d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.env diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ddb4bcf --- /dev/null +++ b/AGENTS.md @@ -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`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3704756 --- /dev/null +++ b/README.md @@ -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) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8224f10 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/dnslog.py b/backend/app/dnslog.py new file mode 100644 index 0000000..bfafd70 --- /dev/null +++ b/backend/app/dnslog.py @@ -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\S+)\s+from\s+(?P\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)) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..95d3ad9 --- /dev/null +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/scanner.py b/backend/app/scanner.py new file mode 100644 index 0000000..f91a3eb --- /dev/null +++ b/backend/app/scanner.py @@ -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 diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..0250017 --- /dev/null +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/settings.py b/backend/app/settings.py new file mode 100644 index 0000000..332e261 --- /dev/null +++ b/backend/app/settings.py @@ -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() diff --git a/backend/app/state.py b/backend/app/state.py new file mode 100644 index 0000000..de67d00 --- /dev/null +++ b/backend/app/state.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3c23e6e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +pydantic==2.11.7 +pydantic-settings==2.11.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..00d0886 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/mikrotik-setup.md b/docs/mikrotik-setup.md new file mode 100644 index 0000000..6b4e306 --- /dev/null +++ b/docs/mikrotik-setup.md @@ -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://:8088/api/ingest/dns` + +For now in MVP, easiest path is log tailing from local DNS server logs (`DNS_LOG_PATH` in `.env`). diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..b53525d --- /dev/null +++ b/frontend/app.js @@ -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 = ` + ${safe(d.status)} + ${safe(d.ip)} + ${safe(d.hostname || d.name || d.ip)} + ${safe(d.type)} + ${safe(d.vendor || "-")} + ${formatTs(d.last_seen)} + `; + 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); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3a51982 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,56 @@ + + + + + + Network Topology Wrapper + + + +
+
+

Network Topology Live View

+

Internal DevOps topology, discovery, and device presence

+
+
+ Waiting for data... +
+
+ +
+
+
+

Topology

+ +
+ +
+ +
+

Devices

+
+ + + + + + + + + + + + +
StatusIPNameTypeVendorLast Seen
+
+
+ +
+

Live Events

+
    +
    +
    + + + + diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..aaddc5d --- /dev/null +++ b/frontend/styles.css @@ -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; + } +} diff --git a/scripts/run-local.sh b/scripts/run-local.sh new file mode 100755 index 0000000..149fcc6 --- /dev/null +++ b/scripts/run-local.sh @@ -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