feat: initial commit
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
@@ -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`.
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -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))
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.116.1
|
||||||
|
uvicorn[standard]==0.35.0
|
||||||
|
pydantic==2.11.7
|
||||||
|
pydantic-settings==2.11.0
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+12
@@ -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
|
||||||
Reference in New Issue
Block a user