From 5f8a43e6529b5a0c38d2827bf95aff56c9094210 Mon Sep 17 00:00:00 2001 From: danielmorkcode Date: Sun, 7 Jun 2026 19:31:43 +0200 Subject: [PATCH] feat: initial release - MorkNetVizualiser v1.0 --- .gitignore | 21 + README.md | 285 ++++++++ backend/.env.example | 27 + backend/Dockerfile | 12 + backend/main.py | 598 ++++++++++++++++ backend/requirements.txt | 6 + docker-compose.yml | 29 + frontend/Dockerfile | 20 + frontend/index.html | 15 + frontend/nginx.conf | 26 + frontend/package.json | 20 + frontend/src/App.jsx | 1195 +++++++++++++++++++++++++++++++ frontend/src/DraggablePanel.jsx | 88 +++ frontend/src/index.css | 400 +++++++++++ frontend/src/index.jsx | 7 + frontend/src/useLocalStorage.js | 22 + frontend/src/useNetMapSocket.js | 80 +++ frontend/src/utils.js | 87 +++ frontend/vite.config.js | 16 + 19 files changed, 2954 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/DraggablePanel.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/index.jsx create mode 100644 frontend/src/useLocalStorage.js create mode 100644 frontend/src/useNetMapSocket.js create mode 100644 frontend/src/utils.js create mode 100644 frontend/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18c0ea7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Environment — contains secrets, never commit +backend/.env + +# GeoIP database — large binary, download separately +data/ + +# Node +frontend/node_modules/ +frontend/build/ + +# Python +__pycache__/ +*.pyc +*.pyo + +# Docker +*.log + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..b21f3a9 --- /dev/null +++ b/README.md @@ -0,0 +1,285 @@ +# NetMap + +A real-time network traffic visualization tool for home and small office networks. +It displays live traffic flows as animated arcs on a world map, showing where your +devices are connecting to and where inbound traffic originates from. + +--- + +## Important disclaimer + +This project was built entirely through AI-assisted development (Claude by Anthropic). +No human has audited, reviewed, or manually written the code. Use it at your own risk +in trusted network environments. Do not expose it to the public internet without +adding authentication in front of it (e.g. via a reverse proxy with access control). + +--- + +## What it does + +- Captures real network flows via NetFlow v9/IPFIX from your router +- Geolocates source and destination IPs using a local MaxMind database +- Draws animated arcs on a world map showing traffic direction and volume +- Shows active devices, traffic volume, protocol breakdown, and top talkers +- Overlays CrowdSec banned IPs as markers on the map (optional) +- Displays a day/night overlay based on current solar position +- Supports arc history replay, connection heatmap, device grouping, and more + +--- + +## Requirements + +### Network + +- A router that supports NetFlow v9 or IPFIX export (tested with UniFi Dream Router) +- The router must be able to reach the host running this tool over UDP + +### Host + +- Docker and Docker Compose +- At least 512 MB RAM +- Network reachable from the router for NetFlow (UDP) and from your browser over HTTP + +### Accounts and files + +**MaxMind GeoLite2 (required)** + +NetFlow gives you IP addresses. MaxMind translates those IPs into geographic coordinates +for map display. Without it the map will show no arcs. + +1. Create a free account at https://www.maxmind.com/en/geolite2/signup +2. Download GeoLite2-City in the binary .mmdb format +3. Place it at `data/GeoLite2-City.mmdb` in the project directory + +The database is updated weekly by MaxMind. To refresh it, replace the file and +restart the backend container. + +**UniFi API key (required)** + +Used to fetch device names and active client list. + +1. Log into your UniFi controller +2. Go to Settings -> System -> Advanced -> API Keys +3. Create a new API key and copy it + +**CrowdSec bouncer API key (optional)** + +See the CrowdSec section below. + +--- + +## Tech stack + +**Backend** + +- Python 3.12 +- FastAPI with uvicorn (HTTP API and WebSocket server) +- Native NetFlow v5/v9 parser written in pure Python using the struct module +- geoip2 (MaxMind GeoLite2 database reader) +- httpx (async HTTP client for UniFi and external APIs) + +**Frontend** + +- React 18 with Vite +- d3-geo with Natural Earth projection for the SVG world map +- topojson-client for country geometry +- WebSocket for live data streaming +- All state persistence via localStorage (no external dependencies for UI state) + +**Infrastructure** + +- Docker Compose with two containers: backend (Python) and frontend (nginx serving built React) +- NetFlow collected on UDP (default port 2055) +- nginx proxies WebSocket and API calls internally so only one port is exposed + +--- + +## Installation + +### 1. Clone or download the project + +``` +git clone +cd netmap +``` + +### 2. Place the GeoIP database + +``` +mkdir -p data +cp /path/to/GeoLite2-City.mmdb data/ +``` + +### 3. Configure the environment + +``` +cp backend/.env.example backend/.env +``` + +Edit `backend/.env` and fill in your values. See the configuration section below. + +### 4. Configure NetFlow on your router + +On a UniFi Dream Router: + +1. Go to Settings -> CyberSecure -> Traffic Logging -> NetFlow (IPFIX) +2. Enable it +3. Set collector address to the IP of the host running this tool +4. Set collector port to 2055 (or whatever you set in NETFLOW_PORT) +5. Set version to 9 +6. Set sampling to Off + +### 5. Start the stack + +``` +docker compose up -d --build +``` + +The first build takes 2-3 minutes. After that, open a browser to: + +``` +http://:3500 +``` + +--- + +## Configuration + +All configuration lives in `backend/.env`. Copy `backend/.env.example` as a starting point. + +``` +# UniFi Controller +UNIFI_URL=https://192.168.1.1 # IP or hostname of your UniFi controller +UNIFI_API_KEY= # API key from UniFi settings +UNIFI_SITE=default # Site name, usually "default" +UNIFI_VERIFY_SSL=false # Set to false for self-signed certificates + +# GeoIP +GEOIP_DB_PATH=/data/GeoLite2-City.mmdb + +# NetFlow collector +NETFLOW_PORT=2055 # Must match what your router is configured to send to + +# Home location (shown as the origin point on the map) +HOME_LAT=51.5074 +HOME_LON=-0.1278 +HOME_NAME=Home + +# CrowdSec (optional, leave blank to disable) +CROWDSEC_URL= +CROWDSEC_API_KEY= +``` + +--- + +## CrowdSec integration (optional) + +CrowdSec is an open source threat detection tool. When configured, NetMap will +display banned IPs as red X markers on the map, with new bans showing a brief +pulse animation. You can filter between locally detected bans and the community +blocklist. + +### Setup + +CrowdSec must be running on a host reachable from the NetMap container. +By default, CrowdSec only listens on localhost (127.0.0.1:8080). To allow +remote access from another host, edit `/etc/crowdsec/config.yaml` on the +CrowdSec host: + +```yaml +api: + server: + listen_uri: 0.0.0.0:8080 +``` + +Then restart CrowdSec: + +``` +sudo systemctl restart crowdsec +``` + +Create a bouncer API key: + +``` +sudo cscli bouncers add netmap-reader +``` + +Copy the generated key. Then in `backend/.env`: + +``` +CROWDSEC_URL=http://:8080 +CROWDSEC_API_KEY= +``` + +Restart the backend: + +``` +docker compose restart backend +``` + +--- + +## Updating the GeoIP database + +MaxMind releases updated databases weekly. To update: + +1. Download the new GeoLite2-City.mmdb from your MaxMind account +2. Replace `data/GeoLite2-City.mmdb` +3. Restart the backend: `docker compose restart backend` + +--- + +## Troubleshooting + +**No arcs on the map** + +- Check that NetFlow is enabled on your router and pointing at the correct host and port +- Verify the backend is receiving packets: `curl http://:3500/api/status` + Look for `netflow_stats.packets` incrementing +- Confirm the GeoLite2 database is present: `docker compose exec backend ls /data` +- Check backend logs: `docker compose logs backend` + +**Reconnecting shown in the UI** + +- The WebSocket connection to the backend failed +- Check `docker compose logs backend` for errors +- Verify the backend container is running: `docker compose ps` + +**CrowdSec shows no bans** + +- Run `curl http://:8080/v1/decisions` with the API key header + to verify the CrowdSec API is reachable +- Check that `listen_uri` in `/etc/crowdsec/config.yaml` is `0.0.0.0:8080` + and not `127.0.0.1:8080` + +**ASN/Org shows Unknown** + +- ip-api.com has a rate limit of 45 requests per minute on the free tier +- The backend caches results, so this only affects the first lookup per IP +- If you are clicking many different IPs quickly, wait a moment and try again + +--- + +## Ports + +| Port | Protocol | Purpose | +|------|----------|---------| +| 3500 | TCP | Web interface (nginx, proxies to backend) | +| 2055 | UDP | NetFlow/IPFIX collector | + +--- + +## Data privacy + +All data stays on your host. No traffic data, IP addresses, or device information +is sent anywhere. The only outbound requests are: + +- MaxMind GeoLite2 downloads (manual, on your schedule) +- ASN lookups to ip-api.com and ipinfo.io (triggered by clicking an arc in the UI) +- World map GeoJSON loaded from a CDN on first page load (cdn.jsdelivr.net) + +--- + +## License + +MIT diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..b323b71 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,27 @@ +# ── UniFi Controller (required) ──────────────────────────────────────────────── +UNIFI_URL=https://192.168.1.1 +UNIFI_API_KEY=your_api_key_here +UNIFI_SITE=default +UNIFI_VERIFY_SSL=false # false for self-signed cert (typical for UDR) + +# ── GeoIP database (required) ────────────────────────────────────────────────── +# Download free from https://www.maxmind.com/en/geolite2/signup +GEOIP_DB_PATH=/data/GeoLite2-City.mmdb + +# ── NetFlow/IPFIX (required) ─────────────────────────────────────────────────── +# Must match the collector port set in UniFi → Settings → CyberSecure → NetFlow +NETFLOW_PORT=2055 + +# ── Home location ────────────────────────────────────────────────────────────── +HOME_LAT=59.9139 +HOME_LON=10.7522 +HOME_NAME=Home + +# ── CrowdSec (optional) ──────────────────────────────────────────────────────── +# Leave blank to disable CrowdSec integration entirely. +# All ban markers, toggles, and counters will be hidden from the UI. +# To enable: run on your CrowdSec host: +# sudo cscli bouncers add netmap-reader +# Then fill in the values below: +CROWDSEC_URL= +CROWDSEC_API_KEY= diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..18ed63f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..cb3b048 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,598 @@ +import asyncio +import json +import logging +import os +import time +from contextlib import asynccontextmanager +from collections import defaultdict + +import geoip2.database +import geoip2.errors +import httpx +from dotenv import load_dotenv +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware + +load_dotenv() + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger(__name__) + +# ── Config ──────────────────────────────────────────────────────────────────── +UNIFI_URL = os.getenv("UNIFI_URL", "https://192.168.1.1") +UNIFI_API_KEY = os.getenv("UNIFI_API_KEY", "") +UNIFI_SITE = os.getenv("UNIFI_SITE", "default") +UNIFI_VERIFY_SSL = os.getenv("UNIFI_VERIFY_SSL", "false").lower() == "true" +GEOIP_DB_PATH = os.getenv("GEOIP_DB_PATH", "/data/GeoLite2-City.mmdb") +POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "10")) +HOME_LAT = float(os.getenv("HOME_LAT", "59.9139")) +HOME_LON = float(os.getenv("HOME_LON", "10.7522")) +HOME_NAME = os.getenv("HOME_NAME", "Home") +NETFLOW_PORT = int(os.getenv("NETFLOW_PORT", "2055")) +CROWDSEC_URL = os.getenv("CROWDSEC_URL", "") +CROWDSEC_API_KEY = os.getenv("CROWDSEC_API_KEY", "") + +# ── State ───────────────────────────────────────────────────────────────────── +ws_clients: dict[int, WebSocket] = {} +geo_reader: geoip2.database.Reader | None = None +device_registry: dict[str, dict] = {} +recent_flows: list[dict] = [] +MAX_RECENT = 500 +ip_to_mac: dict[str, str] = {} +netflow_stats = {"packets": 0, "flows": 0, "geolocated": 0, "templates": 0} + +# ── GeoIP ───────────────────────────────────────────────────────────────────── +def load_geoip(): + global geo_reader + if not os.path.exists(GEOIP_DB_PATH): + log.warning(f"GeoIP database not found at {GEOIP_DB_PATH}") + return + try: + geo_reader = geoip2.database.Reader(GEOIP_DB_PATH) + log.info("GeoIP database loaded.") + except Exception as e: + log.error(f"Failed to load GeoIP: {e}") + +def geolocate(ip: str) -> dict | None: + if not geo_reader: + return None + try: + r = geo_reader.city(ip) + if r.location.latitude is None: + return None + return {"lat": r.location.latitude, "lon": r.location.longitude, + "city": r.city.name or "", "country": r.country.name or "", + "country_code": r.country.iso_code or ""} + except (geoip2.errors.AddressNotFoundError, Exception): + return None + +def is_private_ip(ip: str) -> bool: + import ipaddress + try: + addr = ipaddress.ip_address(ip) + return addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_multicast + except ValueError: + return True + +def int_to_ip(n: int) -> str: + return f"{(n >> 24) & 0xff}.{(n >> 16) & 0xff}.{(n >> 8) & 0xff}.{n & 0xff}" + +# ── NetFlow v5 parser ───────────────────────────────────────────────────────── +import struct + +NF5_HEADER = struct.Struct("!HHIIIhHBB") # 24 bytes +NF5_RECORD = struct.Struct("!IIIHHIIIIHHBBBBHHBBH") # 48 bytes + +def parse_netflow_v5(data: bytes) -> list[dict]: + if len(data) < 24: + return [] + hdr = NF5_HEADER.unpack_from(data, 0) + count = hdr[1] + flows = [] + for i in range(count): + off = 24 + i * 48 + if off + 48 > len(data): + break + r = NF5_RECORD.unpack_from(data, off) + src_ip = int_to_ip(r[0]) + dst_ip = int_to_ip(r[1]) + packets = r[6] + octets = r[7] + proto = r[14] + src_port = r[10] + dst_port = r[11] + flows.append({"src": src_ip, "dst": dst_ip, "bytes": octets, + "packets": packets, "proto": proto, + "src_port": src_port, "dst_port": dst_port}) + return flows + +# ── NetFlow v9 parser ───────────────────────────────────────────────────────── +# Store templates per (src_addr, source_id, template_id) +nf9_templates: dict[tuple, dict] = {} + +NF9_FIELD_TYPES = { + 1: ("IN_BYTES", 4), + 2: ("IN_PKTS", 4), + 4: ("PROTOCOL", 1), + 7: ("L4_SRC_PORT", 2), + 8: ("IPV4_SRC_ADDR", 4), + 11: ("L4_DST_PORT", 2), + 12: ("IPV4_DST_ADDR", 4), + 16: ("SRC_AS", 4), + 17: ("DST_AS", 4), + 21: ("LAST_SWITCHED", 4), + 22: ("FIRST_SWITCHED", 4), + 23: ("OUT_BYTES", 4), + 24: ("OUT_PKTS", 4), + 32: ("ICMP_TYPE", 2), + 60: ("IP_PROTOCOL_VERSION", 1), +} + +def parse_nf9_template(data: bytes, offset: int, length: int, src: str, source_id: int): + end = offset + length + pos = offset + while pos + 4 <= end: + tmpl_id = struct.unpack_from("!H", data, pos)[0] + field_count = struct.unpack_from("!H", data, pos + 2)[0] + pos += 4 + fields = [] + for _ in range(field_count): + if pos + 4 > end: + break + ftype = struct.unpack_from("!H", data, pos)[0] + flen = struct.unpack_from("!H", data, pos + 2)[0] + fields.append((ftype, flen)) + pos += 4 + key = (src, source_id, tmpl_id) + nf9_templates[key] = fields + netflow_stats["templates"] += 1 + log.debug(f"Stored NF9 template {tmpl_id} from {src} ({len(fields)} fields)") + +def parse_nf9_data(data: bytes, offset: int, length: int, + src: str, source_id: int, flowset_id: int) -> list[dict]: + key = (src, source_id, flowset_id) + template = nf9_templates.get(key) + if not template: + return [] + + record_len = sum(f[1] for f in template) + if record_len == 0: + return [] + + flows = [] + pos = offset + end = offset + length + while pos + record_len <= end: + record = {} + rpos = pos + for ftype, flen in template: + raw = data[rpos:rpos + flen] + rpos += flen + name = NF9_FIELD_TYPES.get(ftype, (None, None))[0] + if name == "IPV4_SRC_ADDR" and flen == 4: + record["src"] = int_to_ip(struct.unpack("!I", raw)[0]) + elif name == "IPV4_DST_ADDR" and flen == 4: + record["dst"] = int_to_ip(struct.unpack("!I", raw)[0]) + elif name == "IN_BYTES" and flen <= 4: + record["bytes"] = int.from_bytes(raw, "big") + elif name == "IN_PKTS" and flen <= 4: + record["packets"] = int.from_bytes(raw, "big") + elif name == "PROTOCOL" and flen == 1: + record["proto"] = raw[0] + elif name == "L4_SRC_PORT" and flen == 2: + record["src_port"] = struct.unpack("!H", raw)[0] + elif name == "L4_DST_PORT" and flen == 2: + record["dst_port"] = struct.unpack("!H", raw)[0] + if "src" in record and "dst" in record: + flows.append(record) + pos += record_len + + return flows + +def parse_netflow_v9(data: bytes, src_addr: str) -> list[dict]: + if len(data) < 20: + return [] + count = struct.unpack_from("!H", data, 2)[0] + source_id = struct.unpack_from("!I", data, 16)[0] + pos = 20 + all_flows = [] + + for _ in range(count): + if pos + 4 > len(data): + break + flowset_id = struct.unpack_from("!H", data, pos)[0] + length = struct.unpack_from("!H", data, pos + 2)[0] + if length < 4: + break + payload_off = pos + 4 + payload_len = length - 4 + + if flowset_id == 0: + # Template flowset + parse_nf9_template(data, payload_off, payload_len, src_addr, source_id) + elif flowset_id == 1: + pass # Options template, skip + elif flowset_id >= 256: + # Data flowset + flows = parse_nf9_data(data, payload_off, payload_len, + src_addr, source_id, flowset_id) + all_flows.extend(flows) + + pos += length + # Align to 4-byte boundary + if pos % 4: + pos += 4 - (pos % 4) + + return all_flows + +# ── Flow processing ─────────────────────────────────────────────────────────── +# Deduplicate noisy flows: track recent (src,dst) pairs +recent_pairs: dict[tuple, float] = {} +DEDUP_WINDOW = 30 # seconds — don't re-emit same src→dst within this window + +def process_raw_flow(raw: dict) -> dict | None: + src = raw.get("src", "") + dst = raw.get("dst", "") + if not src or not dst: + return None + + src_local = is_private_ip(src) + dst_local = is_private_ip(dst) + + # Skip purely local flows + if src_local and dst_local: + return None + + now_ts = time.time() + now_ms = int(now_ts * 1000) + + if src_local and not dst_local: + # Outbound + pair_key = ("out", src, dst) + if now_ts - recent_pairs.get(pair_key, 0) < DEDUP_WINDOW: + return None + recent_pairs[pair_key] = now_ts + + geo = geolocate(dst) + mac = ip_to_mac.get(src, "") + name = device_registry.get(mac, {}).get("name") or src + return {"id": f"nf-{src}-{dst}-{now_ms}", "type": "outbound", + "device_mac": mac, "device_name": name, "device_ip": src, + "bytes": raw.get("bytes", 0), "packets": raw.get("packets", 0), + "proto": raw.get("proto", 0), + "src_port": raw.get("src_port", 0), "dst_port": raw.get("dst_port", 0), + "remote_ip": dst, "src_lat": HOME_LAT, "src_lon": HOME_LON, + "timestamp": now_ms, "remote_geo": geo} + + elif not src_local and dst_local: + # Inbound + pair_key = ("in", src, dst) + if now_ts - recent_pairs.get(pair_key, 0) < DEDUP_WINDOW: + return None + recent_pairs[pair_key] = now_ts + + geo = geolocate(src) + mac = ip_to_mac.get(dst, "") + name = device_registry.get(mac, {}).get("name") or dst + return {"id": f"nf-{src}-{dst}-{now_ms}", "type": "inbound", + "device_mac": mac, "device_name": name, "device_ip": dst, + "bytes": raw.get("bytes", 0), "packets": raw.get("packets", 0), + "proto": raw.get("proto", 0), + "src_port": raw.get("src_port", 0), "dst_port": raw.get("dst_port", 0), + "remote_ip": src, "src_lat": HOME_LAT, "src_lon": HOME_LON, + "timestamp": now_ms, "remote_geo": geo} + + return None + +# ── NetFlow UDP server ──────────────────────────────────────────────────────── +class NetFlowProtocol(asyncio.DatagramProtocol): + def __init__(self): + self._loop = asyncio.get_event_loop() + + def datagram_received(self, data: bytes, addr): + netflow_stats["packets"] += 1 + src_addr = addr[0] + try: + if len(data) < 2: + return + version = struct.unpack_from("!H", data, 0)[0] + + if version == 5: + raw_flows = parse_netflow_v5(data) + elif version == 9: + raw_flows = parse_netflow_v9(data, src_addr) + else: + log.debug(f"Unknown NetFlow version {version}") + return + + good_flows = [] + for raw in raw_flows: + flow = process_raw_flow(raw) + if flow: + good_flows.append(flow) + netflow_stats["flows"] += 1 + if flow.get("remote_geo"): + netflow_stats["geolocated"] += 1 + + if good_flows: + self._loop.create_task(emit_flows(good_flows)) + + except Exception as e: + log.error(f"NetFlow parse error: {e}", exc_info=True) + +async def emit_flows(flows: list[dict]): + recent_flows.extend(flows) + if len(recent_flows) > MAX_RECENT: + del recent_flows[:-MAX_RECENT] + await broadcast({"type": "flows", "flows": flows}) + +async def start_netflow_server(): + loop = asyncio.get_event_loop() + try: + await loop.create_datagram_endpoint( + NetFlowProtocol, local_addr=("0.0.0.0", NETFLOW_PORT) + ) + log.info(f"NetFlow UDP collector listening on port {NETFLOW_PORT}") + except Exception as e: + log.error(f"Failed to start NetFlow listener: {e}") + +# ── UniFi API ───────────────────────────────────────────────────────────────── +def make_unifi_client() -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=UNIFI_URL, + headers={"X-API-KEY": UNIFI_API_KEY, "Accept": "application/json"}, + verify=UNIFI_VERIFY_SSL, timeout=10.0, + ) + +async def fetch_clients(http: httpx.AsyncClient) -> list[dict]: + try: + r = await http.get(f"/proxy/network/api/s/{UNIFI_SITE}/stat/sta") + r.raise_for_status() + return r.json().get("data", []) + except Exception as e: + log.error(f"Error fetching clients: {e}") + return [] + +async def fetch_known_clients(http: httpx.AsyncClient) -> list[dict]: + try: + r = await http.get(f"/proxy/network/api/s/{UNIFI_SITE}/rest/user") + r.raise_for_status() + return r.json().get("data", []) + except Exception as e: + log.error(f"Error fetching known clients: {e}") + return [] + +# ── Broadcast ───────────────────────────────────────────────────────────────── +async def broadcast(msg: dict): + dead = [] + for cid, ws in ws_clients.items(): + try: + await ws.send_text(json.dumps(msg)) + except Exception: + dead.append(cid) + for cid in dead: + ws_clients.pop(cid, None) + +# ── Poll loop ───────────────────────────────────────────────────────────────── +async def poll_loop(): + log.info("Starting UniFi poll loop...") + async with make_unifi_client() as http: + known = await fetch_known_clients(http) + for k in known: + mac = k.get("mac", "") + if mac: + device_registry[mac] = { + "mac": mac, + "name": k.get("name") or k.get("hostname") or mac, + "oui": k.get("oui", ""), "ip": k.get("last_ip", ""), + "tx_bytes": 0, "rx_bytes": 0, + } + log.info(f"Loaded {len(device_registry)} known devices.") + + while True: + try: + active = await fetch_clients(http) + for sta in active: + mac = sta.get("mac", "") + if not mac: + continue + ip = sta.get("ip", "") + if ip: + ip_to_mac[ip] = mac + tx = sta.get("wired-tx_bytes") or sta.get("tx_bytes") or 0 + rx = sta.get("wired-rx_bytes") or sta.get("rx_bytes") or 0 + device_registry[mac] = { + "mac": mac, + "name": (sta.get("name") or sta.get("hostname") or + device_registry.get(mac, {}).get("name") or mac), + "ip": ip, "oui": sta.get("oui", ""), + "is_wired": sta.get("is_wired", False), + "uptime": sta.get("uptime"), "signal": sta.get("signal"), + "tx_bytes": tx, "rx_bytes": rx, + "last_seen": sta.get("last_seen"), + } + await broadcast({"type": "devices", "devices": list(device_registry.values())}) + except Exception as e: + log.error(f"Poll loop error: {e}", exc_info=True) + await asyncio.sleep(POLL_INTERVAL) + +# ── Cleanup task ───────────────────────────────────────────────────────────── +async def cleanup_loop(): + """Periodically clean up the dedup table so it doesn't grow forever.""" + while True: + await asyncio.sleep(120) + cutoff = time.time() - DEDUP_WINDOW * 2 + stale = [k for k, t in recent_pairs.items() if t < cutoff] + for k in stale: + recent_pairs.pop(k, None) + +# ── FastAPI ─────────────────────────────────────────────────────────────────── +@asynccontextmanager +async def lifespan(app: FastAPI): + load_geoip() + await start_netflow_server() + poll_task = asyncio.create_task(poll_loop()) + cleanup_task = asyncio.create_task(cleanup_loop()) + yield + poll_task.cancel() + cleanup_task.cancel() + +app = FastAPI(lifespan=lifespan) +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +@app.get("/api/devices") +async def get_devices(): + return {"devices": list(device_registry.values())} + +@app.get("/api/status") +async def get_status(): + return { + "geoip_loaded": geo_reader is not None, + "device_count": len(device_registry), + "home": {"lat": HOME_LAT, "lon": HOME_LON, "name": HOME_NAME}, + "netflow_port": NETFLOW_PORT, + "netflow_stats": netflow_stats, + "templates_learned": len(nf9_templates), + } + +@app.get("/api/crowdsec/enabled") +async def get_crowdsec_enabled(): + """Returns whether CrowdSec is configured.""" + return {"enabled": bool(CROWDSEC_URL and CROWDSEC_API_KEY)} + +@app.get("/api/crowdsec") +async def get_crowdsec(origin: str = "all"): + """Proxy CrowdSec decisions with geo enrichment. origin=all|local|capi""" + if not CROWDSEC_URL or not CROWDSEC_API_KEY: + return [] + try: + async with httpx.AsyncClient(timeout=8.0) as http: + # Fetch all decisions — filter locally since CrowdSec API origin param is unreliable + r = await http.get( + f"{CROWDSEC_URL}/v1/decisions", + headers={"X-Api-Key": CROWDSEC_API_KEY}, + params={"limit": 2000} + ) + if r.status_code == 204: + return [] + r.raise_for_status() + decisions = r.json() + if not decisions: + return [] + + # Filter by origin locally — handles any casing CrowdSec uses + if origin == "local": + decisions = [d for d in decisions if d.get("origin", "").lower() not in ("capi", "lists")] + elif origin == "capi": + decisions = [d for d in decisions if d.get("origin", "").upper() == "CAPI"] + + results = [] + for d in decisions: + ip = d.get("value", "") + ip_clean = ip.split("/")[0] + geo = geolocate(ip_clean) if ip_clean else None + results.append({ + "ip": ip, + "geo": geo, + "reason": d.get("reason", ""), + "scenario": d.get("scenario", d.get("reason", "")), + "origin": d.get("origin", ""), + "type": d.get("type", "ban"), + "expires_in": d.get("duration", ""), + "scope": d.get("scope", ""), + }) + return results + except Exception as e: + log.error(f"CrowdSec fetch error: {e}") + return [] + + +@app.get("/api/crowdsec/counts") +async def get_crowdsec_counts(): + """Return ban counts per origin without geo enrichment (fast).""" + if not CROWDSEC_URL or not CROWDSEC_API_KEY: + return {"local": 0, "capi": 0, "total": 0} + try: + async with httpx.AsyncClient(timeout=8.0) as http: + r = await http.get( + f"{CROWDSEC_URL}/v1/decisions", + headers={"X-Api-Key": CROWDSEC_API_KEY}, + params={"limit": 5000} + ) + if r.status_code == 204: + return {"local": 0, "capi": 0, "total": 0} + r.raise_for_status() + decisions = r.json() or [] + capi = sum(1 for d in decisions if d.get("origin","").upper() == "CAPI") + local = sum(1 for d in decisions if d.get("origin","").lower() not in ("capi","lists")) + return {"local": local, "capi": capi, "total": len(decisions)} + except Exception as e: + log.error(f"CrowdSec counts error: {e}") + return {"local": 0, "capi": 0, "total": 0} + + +@app.get("/api/rdns/{ip}") +async def get_rdns(ip: str): + """Reverse DNS lookup.""" + import socket + try: + hostname = await asyncio.get_event_loop().run_in_executor( + None, lambda: socket.gethostbyaddr(ip)[0] + ) + return {"hostname": hostname} + except Exception: + return {"hostname": None} + +# Server-side ASN cache to avoid hammering ip-api.com +_asn_cache: dict[str, str] = {} + +@app.get("/api/asn/{ip}") +async def get_asn(ip: str): + """Proxy ASN/org lookup with caching and fallback.""" + if ip in _asn_cache: + return {"org": _asn_cache[ip]} + try: + async with httpx.AsyncClient(timeout=5.0) as http: + # Primary: ip-api.com (45 req/min free) + r = await http.get( + f"http://ip-api.com/json/{ip}", + params={"fields": "org,isp,status"} + ) + if r.status_code == 200: + d = r.json() + if d.get("status") == "success": + org = d.get("org") or d.get("isp") or "Unknown" + _asn_cache[ip] = org + return {"org": org} + # Fallback: ipinfo.io + r2 = await http.get(f"https://ipinfo.io/{ip}/json") + if r2.status_code == 200: + d2 = r2.json() + org = d2.get("org") or "Unknown" + _asn_cache[ip] = org + return {"org": org} + except Exception as e: + log.debug(f"ASN lookup error for {ip}: {e}") + return {"org": "Unknown"} + + +@app.websocket("/ws") +async def websocket_endpoint(ws: WebSocket): + await ws.accept() + cid = id(ws) + ws_clients[cid] = ws + log.info(f"WS client connected. Total: {len(ws_clients)}") + try: + await ws.send_text(json.dumps({ + "type": "init", + "home": {"lat": HOME_LAT, "lon": HOME_LON, "name": HOME_NAME}, + "geoip_loaded": geo_reader is not None, + "devices": list(device_registry.values()), + "recent_flows": recent_flows[-50:], + })) + while True: + await ws.receive_text() + except WebSocketDisconnect: + log.info(f"WS client disconnected.") + finally: + ws_clients.pop(cid, None) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..4072d67 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +websockets==12.0 +httpx==0.27.0 +geoip2==4.8.0 +python-dotenv==1.0.1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5fee3e5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + backend: + build: ./backend + restart: unless-stopped + env_file: ./backend/.env + volumes: + - ./data:/data + ports: + - "2055:2055/udp" # NetFlow/IPFIX collector from UDR + networks: + - netmap + + frontend: + build: + context: ./frontend + args: + # When nginx proxies /ws internally, the browser connects to the same host:port + REACT_APP_WS_URL: "" # leave blank — nginx proxies /ws to backend + restart: unless-stopped + ports: + - "3500:80" # Access at http://:3500 + depends_on: + - backend + networks: + - netmap + +networks: + netmap: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..21bcd88 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine AS build + +WORKDIR /app +COPY package.json . +RUN npm install + +COPY index.html . +COPY vite.config.js . +COPY public/ public/ +COPY src/ src/ + +ARG REACT_APP_WS_URL +ENV VITE_WS_URL=$REACT_APP_WS_URL + +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/build /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..aa0a667 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + NetMap — Network Traffic Globe + + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..44a048f --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # Proxy WebSocket to backend + location /ws { + proxy_pass http://backend:8000/ws; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 86400; + } + + # Proxy API to backend + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_set_header Host $host; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..355ccfa --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "netmap-frontend", + "version": "1.0.0", + "private": true, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "d3-geo": "^3.1.1", + "topojson-client": "^3.1.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.0" + }, + "scripts": { + "start": "vite", + "build": "vite build", + "preview": "vite preview" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..3cbea11 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,1195 @@ +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import { geoNaturalEarth1, geoPath } from 'd3-geo'; +import { useNetMapSocket } from './useNetMapSocket.js'; +import { formatBytes, deviceIcon, OUT_COLOR, IN_COLOR, protoName, protoColor, serviceLabel, DEVICE_GROUPS, GROUP_COLORS } from './utils.js'; +import { useLocalStorage } from './useLocalStorage.js'; +import { DraggablePanel } from './DraggablePanel.jsx'; + +const BLOCKED_COLOR = '#cc2200'; +const ARC_LIFETIME_MS = 12000; +const W = 1800; +const H = 900; + +const DEFAULT_THEME = { + bg: '#040810', land: '#080f1c', coast: '#1a5a9a', borders: '#0d3356', grid: '#38bdf8', +}; + +const PRESETS = [ + { name: 'Default', theme: DEFAULT_THEME }, + { name: 'Ember', theme: { bg:'#0a0603', land:'#1a0c06', coast:'#c45000', borders:'#6b2800', grid:'#ff6a00' } }, + { name: 'Arctic', theme: { bg:'#020a10', land:'#071825', coast:'#40c4d0', borders:'#0d4050', grid:'#80e8f0' } }, + { name: 'Jungle', theme: { bg:'#020a04', land:'#071a09', coast:'#2ea84a', borders:'#0d3d15', grid:'#40d060' } }, + { name: 'Neon', theme: { bg:'#06000f', land:'#110020', coast:'#cc00ff', borders:'#550080', grid:'#ff44ff' } }, + { name: 'Sand', theme: { bg:'#0e0b06', land:'#1e1708', coast:'#b8922a', borders:'#6b5010', grid:'#e0b840' } }, +]; + +const projection = geoNaturalEarth1().scale(280).translate([W / 2, H / 2]); +const pathGen = geoPath(projection); +function proj(lon, lat) { return projection([lon, lat]); } + +export default function App() { + const { connected, devices, flows, home, geoipLoaded } = useNetMapSocket(); + + // ── World ─────────────────────────────────────────────────────────────────── + const [worldData, setWorldData] = useState(null); + useEffect(() => { + fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json') + .then(r => r.json()) + .then(topo => { + import('topojson-client').then(({ feature, mesh }) => { + setWorldData({ + countries: feature(topo, topo.objects.countries), + borders: mesh(topo, topo.objects.countries, (a,b) => a !== b), + land: mesh(topo, topo.objects.land), + }); + }); + }); + }, []); + + // ── Zoom/pan ───────────────────────────────────────────────────────────────── + const [vb, setVb] = useState({ x:0, y:0, w:W, h:H }); + const dragRef = useRef(null); + const svgRef = useRef(null); + + const clampVb = useCallback((x,y,w,h) => { + w = Math.max(W/12, Math.min(W*2, w)); + h = w*(H/W); + x = Math.max(-W*0.5, Math.min(W*1.5-w, x)); + y = Math.max(-H*0.5, Math.min(H*1.5-h, y)); + return {x,y,w,h}; + }, []); + + const handleWheel = useCallback((e) => { + e.preventDefault(); + const svg = svgRef.current; if (!svg) return; + const rect = svg.getBoundingClientRect(); + const mx = (e.clientX-rect.left)/rect.width*vb.w+vb.x; + const my = (e.clientY-rect.top)/rect.height*vb.h+vb.y; + const factor = e.deltaY < 0 ? 0.85 : 1.18; + const nw = vb.w*factor, nh = vb.h*factor; + setVb(clampVb(mx-(mx-vb.x)*(nw/vb.w), my-(my-vb.y)*(nh/vb.h), nw, nh)); + }, [vb, clampVb]); + + useEffect(() => { + const el = svgRef.current; if (!el) return; + el.addEventListener('wheel', handleWheel, { passive:false }); + return () => el.removeEventListener('wheel', handleWheel); + }, [handleWheel]); + + const handleMouseDown = useCallback((e) => { + if (e.button !== 0) return; + const rect = svgRef.current.getBoundingClientRect(); + dragRef.current = { startX:e.clientX, startY:e.clientY, origX:vb.x, origY:vb.y, + scaleX:vb.w/rect.width, scaleY:vb.h/rect.height }; + e.preventDefault(); + }, [vb]); + + const handleMouseMove = useCallback((e) => { + if (!dragRef.current) return; + const d = dragRef.current; + setVb(prev => clampVb(d.origX-(e.clientX-d.startX)*d.scaleX, + d.origY-(e.clientY-d.startY)*d.scaleY, prev.w, prev.h)); + }, [clampVb]); + + const handleMouseUp = useCallback(() => { dragRef.current = null; }, []); + + const zoomIn = () => setVb(v => { const nw=v.w*0.75; return clampVb(v.x+(v.w-nw)/2,v.y+(v.h-nw*H/W)/2,nw,nw*H/W); }); + const zoomOut = () => setVb(v => { const nw=v.w*1.33; return clampVb(v.x+(v.w-nw)/2,v.y+(v.h-nw*H/W)/2,nw,nw*H/W); }); + const zoomReset = () => setVb({x:0,y:0,w:W,h:H}); + const currentScale = W/vb.w; + const invScale = 1/currentScale; + const viewBox = `${vb.x} ${vb.y} ${vb.w} ${vb.h}`; + + // ── Persistent prefs ───────────────────────────────────────────────────────── + const [showInbound, setShowInbound] = useLocalStorage('netmap-inbound', true); + const [showOutbound, setShowOutbound] = useLocalStorage('netmap-outbound', true); + const [glowEnabled, setGlowEnabled] = useLocalStorage('netmap-glow', false); + const [showNight, setShowNight] = useLocalStorage('netmap-night', true); + const [showCrowdsec, setShowCrowdsec] = useLocalStorage('netmap-crowdsec', true); + const [crowdsecOrigin,setCrowdsecOrigin]= useLocalStorage('netmap-crowdsec-origin', 'local'); + const [theme, setTheme] = useLocalStorage('netmap-theme', DEFAULT_THEME); + const [openPanels, setOpenPanels] = useLocalStorage('netmap-panels', ['devices']); + const [showArcAnim, setShowArcAnim] = useLocalStorage('netmap-arcanim', true); + const [deviceGroups, setDeviceGroups] = useLocalStorage('netmap-groups', {}); + // deviceGroups: { mac: 'IoT' | 'Servers' | 'Mobile' | 'Network' | '' } + + const togglePanel = useCallback((id) => { + setOpenPanels(prev => prev.includes(id) ? prev.filter(p=>p!==id) : [...prev, id]); + }, [setOpenPanels]); + + // ── UI state ───────────────────────────────────────────────────────────────── + const [selectedDevice, setSelectedDevice] = useState(null); + const [selectedCountry, setSelectedCountry] = useState(null); // alpha2 code + const [selectedGroup, setSelectedGroup] = useState(null); // group name filter + const [search, setSearch] = useState(''); + const [selectedArc, setSelectedArc] = useState(null); + const [selectedBan, setSelectedBan] = useState(null); + const [showPicker, setShowPicker] = useState(false); + const [tick, setTick] = useState(0); + + // ── Clock ──────────────────────────────────────────────────────────────────── + const [now, setNow] = useState(new Date()); + useEffect(() => { + const t = setInterval(() => { setNow(new Date()); setTick(n=>n+1); }, 1000); + return () => clearInterval(t); + }, []); + const clockTime = now.toLocaleTimeString('no-NO', {hour:'2-digit',minute:'2-digit',second:'2-digit'}); + const clockDate = now.toLocaleDateString('no-NO', {weekday:'short',year:'numeric',month:'short',day:'numeric'}); + + // ── CrowdSec ───────────────────────────────────────────────────────────────── + const [crowdsecBans, setCrowdsecBans] = useState([]); + const [crowdsecCounts, setCrowdsecCounts] = useState({local:0,capi:0,total:0}); + const [crowdsecEnabled, setCrowdsecEnabled] = useState(false); + + useEffect(() => { + fetch('/api/crowdsec/enabled').then(r=>r.json()).then(d=>setCrowdsecEnabled(d.enabled)).catch(()=>{}); + }, []); + + useEffect(() => { + async function fetchBans() { + try { + const r = await fetch(`/api/crowdsec?origin=${crowdsecOrigin}`); + if (!r.ok) return; + const data = await r.json(); + setCrowdsecBans(prev => { + const prevIps = new Set(prev.map(b=>b.ip)); + return data.map(b=>({...b, isNew:!prevIps.has(b.ip)})); + }); + } catch {} + } + async function fetchCounts() { + try { + const r = await fetch('/api/crowdsec/counts'); + if (!r.ok) return; + setCrowdsecCounts(await r.json()); + } catch {} + } + fetchBans(); fetchCounts(); + const t1=setInterval(fetchBans,30000), t2=setInterval(fetchCounts,60000); + return () => { clearInterval(t1); clearInterval(t2); }; + }, [crowdsecOrigin]); + + // ── Day/Night ───────────────────────────────────────────────────────────────── + const nightPath = useMemo(() => { + const d = now; + const dayOfYear = Math.floor((d - new Date(d.getFullYear(), 0, 0)) / 86400000); + const decl = -23.45 * Math.cos((360 / 365) * (dayOfYear + 10) * Math.PI / 180); + const declRad = decl * Math.PI / 180; + const utcHour = d.getUTCHours() + d.getUTCMinutes() / 60 + d.getUTCSeconds() / 3600; + const sunLon = -(utcHour - 12) * 15; + + // Terminator: 1° resolution to avoid gaps + const terminator = []; + for (let lon = -180; lon <= 180; lon += 1) { + const ha = (lon - sunLon) * Math.PI / 180; + const lat = Math.abs(declRad) < 0.001 + ? (Math.cos(ha) >= 0 ? -89.9 : 89.9) + : Math.atan(-Math.cos(ha) / Math.tan(declRad)) * 180 / Math.PI; + terminator.push([lon, Math.max(-89.9, Math.min(89.9, lat))]); + } + + // Night pole = opposite hemisphere from sun declination + const nightPole = decl > 0 ? -90 : 90; + + // Polygon: trace terminator, sweep around night pole cap + const coords = [ + ...terminator, + [180, nightPole], + [-180, nightPole], + [-180, terminator[0][1]], + ]; + + try { + return pathGen({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [coords] } }); + } catch { return null; } + }, [now.getUTCMinutes()]); + + // ── Devices ─────────────────────────────────────────────────────────────────── + const ACTIVE_CUTOFF = Date.now()/1000 - 15*60; + const activeDevices = useMemo(() => + devices.filter(d => d.ip && d.last_seen && d.last_seen > ACTIVE_CUTOFF), + [devices, tick]); + + // Devices that have flows to the selected country + const countryDeviceMacs = useMemo(() => { + if (!selectedCountry) return null; + const cutoff = Date.now() - 5*60*1000; + const macs = new Set(); + flows.filter(f => f.timestamp>cutoff && f.remote_geo?.country_code===selectedCountry) + .forEach(f => macs.add(f.device_mac)); + return macs; + }, [selectedCountry, flows, tick]); + + const filteredDevices = useMemo(() => { + const q = search.toLowerCase(); + return activeDevices.filter(d => { + if (q && !(d.name||'').toLowerCase().includes(q) && !(d.ip||'').includes(q) && !(d.mac||'').toLowerCase().includes(q)) return false; + if (selectedGroup && deviceGroups[d.mac] !== selectedGroup) return false; + if (countryDeviceMacs && !countryDeviceMacs.has(d.mac)) return false; + return true; + }); + }, [activeDevices, search, selectedGroup, deviceGroups, countryDeviceMacs]); + + // ── Arcs ────────────────────────────────────────────────────────────────────── + const maxBytes = useMemo(() => { + const vals = flows.filter(f=>f.bytes>0).map(f=>f.bytes); + return vals.length ? Math.max(...vals) : 1; + }, [flows]); + + const homeXY = useMemo(() => proj(home.lon, home.lat), [home]); + + const arcs = useMemo(() => { + const now2 = Date.now(); + return flows.filter(f => { + if (!f.remote_geo) return false; + if (now2-f.timestamp > ARC_LIFETIME_MS) return false; + if (f.type==='inbound' && !showInbound) return false; + if (f.type==='outbound' && !showOutbound) return false; + if (selectedDevice && f.device_mac !== selectedDevice) return false; + if (selectedCountry && f.remote_geo.country_code !== selectedCountry) return false; + return true; + }).map(f => { + const age = (now2-f.timestamp)/ARC_LIFETIME_MS; + const rp = proj(f.remote_geo.lon, f.remote_geo.lat); + if (!rp || !homeXY) return null; + const [hx,hy]=homeXY, [rx,ry]=rp; + const dist = Math.hypot(rx-hx,ry-hy); + const path = f.type==='outbound' + ? `M${hx},${hy} Q${(hx+rx)/2},${(hy+ry)/2-dist*0.3} ${rx},${ry}` + : `M${rx},${ry} Q${(hx+rx)/2},${(hy+ry)/2-dist*0.3} ${hx},${hy}`; + const color = f.blocked ? BLOCKED_COLOR : f.type==='outbound' ? OUT_COLOR : IN_COLOR; + const ratio = f.bytes>0 ? Math.log1p(f.bytes)/Math.log1p(maxBytes) : 0.1; + const thickness = 0.6+ratio*3.4; + return {...f, path, age, rx, ry, color, thickness}; + }).filter(Boolean); + }, [flows, showInbound, showOutbound, selectedDevice, homeXY, tick, maxBytes]); + + const remoteDots = useMemo(() => { + const seen = new Map(); + arcs.forEach(a => { const k=`${Math.round(a.rx/4)*4},${Math.round(a.ry/4)*4}`; if (!seen.has(k)) seen.set(k,a); }); + return [...seen.values()]; + }, [arcs]); + + // ── Stats ───────────────────────────────────────────────────────────────────── + const stats = useMemo(() => { + const recent = flows.filter(f=>Date.now()-f.timestamp<30000); + return { + out: recent.filter(f=>f.type==='outbound').reduce((s,f)=>s+(f.bytes||0),0), + inc: recent.filter(f=>f.type==='inbound').reduce((s,f)=>s+(f.bytes||0),0), + blocked: recent.filter(f=>f.blocked).length, + }; + }, [flows, tick]); + + // ── Top talkers ─────────────────────────────────────────────────────────────── + const topTalkers = useMemo(() => { + const cutoff = Date.now()-60000; + const byDev = {}; + flows.filter(f=>f.timestamp>cutoff&&f.bytes>0).forEach(f => { + if (!byDev[f.device_mac]) byDev[f.device_mac]={name:f.device_name||f.device_mac,out:0,inc:0,total:0}; + if (f.type==='outbound') byDev[f.device_mac].out+=f.bytes; + else byDev[f.device_mac].inc+=f.bytes; + byDev[f.device_mac].total+=f.bytes; + }); + return Object.values(byDev).sort((a,b)=>b.total-a.total).slice(0,5); + }, [flows, tick]); + + // ── Protocol breakdown ──────────────────────────────────────────────────────── + const protoStats = useMemo(() => { + const cutoff = Date.now() - 5*60*1000; // last 5 min + const byProto = {}; + flows.filter(f=>f.timestamp>cutoff&&f.proto).forEach(f => { + const p = f.proto; + if (!byProto[p]) byProto[p] = { proto:p, bytes:0, count:0 }; + byProto[p].bytes += f.bytes||0; + byProto[p].count += 1; + }); + const arr = Object.values(byProto).sort((a,b)=>b.bytes-a.bytes); + const total = arr.reduce((s,p)=>s+p.bytes, 0); + return arr.map(p=>({...p, pct: total>0 ? (p.bytes/total*100) : 0})); + }, [flows, tick]); + + // ── Sparklines — per-device bytes per 30s bucket over last 5 min ───────────── + const SPARK_BUCKETS = 10; + const SPARK_WINDOW = 5*60*1000; + const BUCKET_MS = SPARK_WINDOW / SPARK_BUCKETS; + + const sparklines = useMemo(() => { + const nowMs = Date.now(); + const byDev = {}; + flows.filter(f=>f.bytes>0 && nowMs-f.timestamp { + if (!byDev[f.device_mac]) byDev[f.device_mac] = new Array(SPARK_BUCKETS).fill(0); + const bucket = Math.min(SPARK_BUCKETS-1, Math.floor((nowMs-f.timestamp)/BUCKET_MS)); + byDev[f.device_mac][SPARK_BUCKETS-1-bucket] += f.bytes; + }); + return byDev; + }, [flows, tick]); + + // ── Arc history replay ──────────────────────────────────────────────────────── + // flowHistory stores a rolling 1h buffer of flows with geo + const flowHistory = useRef([]); + const HISTORY_WINDOW = 60*60*1000; + + useEffect(() => { + const geoFlows = flows.filter(f=>f.remote_geo); + flowHistory.current = [ + ...flowHistory.current.filter(f=>Date.now()-f.timestamp!flowHistory.current.find(h=>h.id===f.id)) + ].slice(-2000); + }, [flows]); + + const [replayMode, setReplayMode] = useState(false); + const [replayPos, setReplayPos] = useState(100); // 0-100 percent + const [replayPlaying, setReplayPlaying] = useState(false); + const replayTimer = useRef(null); + + useEffect(() => { + if (replayPlaying) { + replayTimer.current = setInterval(() => { + setReplayPos(p => { + if (p >= 100) { setReplayPlaying(false); return 100; } + return p + 0.5; + }); + }, 100); + } else { + clearInterval(replayTimer.current); + } + return () => clearInterval(replayTimer.current); + }, [replayPlaying]); + + // When in replay mode, filter arcs to a 2-minute window around replay position + const replayArcs = useMemo(() => { + if (!replayMode || !flowHistory.current.length) return null; + const hist = flowHistory.current; + const oldest = Math.min(...hist.map(f=>f.timestamp)); + const newest = Math.max(...hist.map(f=>f.timestamp)); + const range = newest - oldest; + const center = oldest + range*(replayPos/100); + const window2min = 2*60*1000; + return hist.filter(f=>Math.abs(f.timestamp-center) { + const rp = proj(f.remote_geo.lon, f.remote_geo.lat); + if (!rp || !homeXY) return null; + const [hx,hy]=homeXY, [rx,ry]=rp; + const dist = Math.hypot(rx-hx,ry-hy); + const path = f.type==='outbound' + ? `M${hx},${hy} Q${(hx+rx)/2},${(hy+ry)/2-dist*0.3} ${rx},${ry}` + : `M${rx},${ry} Q${(hx+rx)/2},${(hy+ry)/2-dist*0.3} ${hx},${hy}`; + const color = f.type==='outbound' ? OUT_COLOR : IN_COLOR; + const ageFrac = Math.abs(f.timestamp-center)/window2min; + return {...f, path, age:ageFrac, rx, ry, color, thickness:1.2}; + }).filter(Boolean); + }, [replayMode, replayPos, homeXY, tick]); + + const replayTimestamp = useMemo(() => { + if (!replayMode || !flowHistory.current.length) return null; + const hist = flowHistory.current; + const oldest = Math.min(...hist.map(f=>f.timestamp)); + const newest = Math.max(...hist.map(f=>f.timestamp)); + return new Date(oldest + (newest-oldest)*(replayPos/100)); + }, [replayMode, replayPos]); + + // Active arcs — either live or replay + const displayArcs = replayMode && replayArcs ? replayArcs : arcs; + + // ── Flow log ────────────────────────────────────────────────────────────────── + const visibleFlows = useMemo(() => [...flows].reverse().filter(f => { + if (f.type==='inbound' && !showInbound) return false; + if (f.type==='outbound' && !showOutbound) return false; + if (selectedDevice && f.device_mac!==selectedDevice) return false; + return true; + }).slice(0,100), [flows, showInbound, showOutbound, selectedDevice]); + + // ── ASN lookup ──────────────────────────────────────────────────────────────── + const asnCache = useRef({}); + const rdnsCache = useRef({}); + const [asnInfo, setAsnInfo] = useState(''); + const [rdnsInfo, setRdnsInfo] = useState(''); + + const lookupASN = useCallback(async (ip) => { + if (!ip) return; + if (asnCache.current[ip]) { setAsnInfo(asnCache.current[ip]); return; } + setAsnInfo('Looking up…'); + try { + const r = await fetch(`/api/asn/${ip}`); + const d = await r.json(); + const info = d.org || 'Unknown'; + asnCache.current[ip] = info; + setAsnInfo(info); + } catch { setAsnInfo('Unavailable'); } + }, []); + + const lookupRDNS = useCallback(async (ip) => { + if (!ip) return; + setRdnsInfo(''); + if (rdnsCache.current[ip] !== undefined) { + if (rdnsCache.current[ip]) setRdnsInfo(rdnsCache.current[ip]); + return; + } + try { + const r = await fetch(`/api/rdns/${ip}`); + const d = await r.json(); + rdnsCache.current[ip] = d.hostname || ''; + if (d.hostname) setRdnsInfo(d.hostname); + } catch {} + }, []); + + const handleArcClick = useCallback((arc, e) => { + e.stopPropagation(); + setSelectedArc(arc); + setAsnInfo(''); + setRdnsInfo(''); + if (arc.remote_ip) { lookupASN(arc.remote_ip); lookupRDNS(arc.remote_ip); } + }, [lookupASN, lookupRDNS]); + + const dismissAll = useCallback(() => { + setSelectedArc(null); setSelectedBan(null); setAsnInfo(''); setRdnsInfo(''); + setSelectedCountry(null); + }, []); + + // ── Theme ───────────────────────────────────────────────────────────────────── + const updateTheme = useCallback((key,val) => setTheme(t=>({...t,[key]:val})), [setTheme]); + const resetTheme = useCallback(() => setTheme(DEFAULT_THEME), [setTheme]); + + // ── Heatmap ─────────────────────────────────────────────────────────────────── + const [showHeatmap, setShowHeatmap] = useLocalStorage('netmap-heatmap', false); + const [hoveredCountry, setHoveredCountry] = useState(null); + + // Map ISO country code → connection count (last 5 min) + const countryCounts = useMemo(() => { + const cutoff = Date.now() - 5*60*1000; + const counts = {}; + flows.filter(f => f.timestamp > cutoff && f.remote_geo?.country_code).forEach(f => { + const cc = f.remote_geo.country_code; + counts[cc] = (counts[cc] || 0) + 1; + }); + return counts; + }, [flows, tick]); + + const maxCountryCount = useMemo(() => + Math.max(1, ...Object.values(countryCounts)), + [countryCounts]); + + // Map country numeric ID → ISO code using the topojson properties + const countryIdToCode = useMemo(() => { + if (!worldData) return {}; + const map = {}; + worldData.countries.features.forEach(f => { + // world-atlas uses numeric ISO 3166-1 ids; we build a reverse lookup + // by matching country names from our flow data isn't reliable, so we + // use the id directly and build a lookup from our flows instead + map[f.id] = f.id; + }); + return map; + }, [worldData]); + + // Build numeric-id → count map via country_code matching + // We need iso-numeric → iso-alpha2 lookup — embed a compact one + const NUM_TO_ALPHA2 = useMemo(() => ({ + 4:'AF',8:'AL',12:'DZ',24:'AO',32:'AR',36:'AU',40:'AT',50:'BD',56:'BE',64:'BT', + 68:'BO',76:'BR',100:'BG',116:'KH',120:'CM',124:'CA',152:'CL',156:'CN',170:'CO', + 180:'CD',188:'CR',191:'HR',192:'CU',196:'CY',203:'CZ',208:'DK',214:'DO',218:'EC', + 818:'EG',222:'SV',231:'ET',246:'FI',250:'FR',276:'DE',288:'GH',300:'GR',320:'GT', + 332:'HT',340:'HN',348:'HU',356:'IN',360:'ID',364:'IR',368:'IQ',372:'IE',376:'IL', + 380:'IT',388:'JM',392:'JP',400:'JO',404:'KE',410:'KR',408:'KP',414:'KW',418:'LA', + 422:'LB',426:'LS',430:'LR',434:'LY',484:'MX',504:'MA',508:'MZ',516:'NA',524:'NP', + 528:'NL',554:'NZ',558:'NI',566:'NG',578:'NO',586:'PK',591:'PA',598:'PG',600:'PY', + 604:'PE',608:'PH',616:'PL',620:'PT',630:'PR',634:'QA',642:'RO',643:'RU',646:'RW', + 682:'SA',686:'SN',694:'SL',706:'SO',710:'ZA',724:'ES',144:'LK',729:'SD',740:'SR', + 752:'SE',756:'CH',760:'SY',762:'TJ',764:'TH',788:'TN',792:'TR',800:'UG',804:'UA', + 784:'AE',826:'GB',840:'US',858:'UY',860:'UZ',862:'VE',704:'VN',887:'YE',894:'ZM', + 716:'ZW',32:'AR',442:'LU',470:'MT',703:'SK',705:'SI',233:'EE',428:'LV',440:'LT', + 112:'BY',498:'MD',70:'BA',807:'MK',499:'ME',688:'RS',8:'AL',51:'AM',31:'AZ', + 268:'GE',398:'KZ',417:'KG',496:'MN',795:'TM',860:'UZ',792:'TR',376:'IL', + }), []); + + const countryFillColor = useCallback((featureId) => { + const alpha2 = NUM_TO_ALPHA2[parseInt(featureId)]; + if (!alpha2) return null; + const count = countryCounts[alpha2] || 0; + if (count === 0) return null; + const ratio = Math.log1p(count) / Math.log1p(maxCountryCount); + // Dark teal → bright cyan scale + const r = Math.round(0 + ratio * 0); + const g = Math.round(40 + ratio * 180); + const b = Math.round(60 + ratio * 195); + const a = 0.08 + ratio * 0.55; + return `rgba(${r},${g},${b},${a})`; + }, [countryCounts, maxCountryCount, NUM_TO_ALPHA2]); + + const maxCountry = useMemo(() => { + const top = Object.entries(countryCounts).sort((a,b)=>b[1]-a[1])[0]; + return top ? { code: top[0], count: top[1] } : null; + }, [countryCounts]); + // ── Traffic timeline — 60 buckets of 1 min each = 1 hour ─────────────────── + const TIMELINE_BUCKETS = 60; + const TIMELINE_WINDOW = 60*60*1000; + const TIMELINE_BUCKET = TIMELINE_WINDOW / TIMELINE_BUCKETS; + + // Frozen completed buckets — only recompute when a new minute starts + const frozenBuckets = useRef({ out: new Array(60).fill(0), inc: new Array(60).fill(0), computedAt: 0 }); + + const timeline = useMemo(() => { + const nowMs = Date.now(); + const currentMinute = Math.floor(nowMs / TIMELINE_BUCKET); + + // Only recompute if minute boundary crossed + if (frozenBuckets.current.computedAt !== currentMinute) { + const out = new Array(TIMELINE_BUCKETS).fill(0); + const inc = new Array(TIMELINE_BUCKETS).fill(0); + const allFlows = [...flows, ...(flowHistory.current || [])]; + // Deduplicate by id + const seen = new Set(); + allFlows.forEach(f => { + if (seen.has(f.id)) return; + seen.add(f.id); + const age = nowMs - f.timestamp; + if (age > TIMELINE_WINDOW || age < 0) return; + const bucket = Math.floor(age / TIMELINE_BUCKET); + if (bucket >= TIMELINE_BUCKETS) return; + const idx = TIMELINE_BUCKETS - 1 - bucket; + if (f.type === 'outbound') out[idx] += f.bytes || 0; + else inc[idx] += f.bytes || 0; + }); + frozenBuckets.current = { out, inc, computedAt: currentMinute }; + } + + const { out, inc } = frozenBuckets.current; + const maxVal = Math.max(1, ...out, ...inc); + const totalOut = out.reduce((s, v) => s + v, 0); + const totalInc = inc.reduce((s, v) => s + v, 0); + return { out, inc, maxVal, totalOut, totalInc }; + }, [flows, tick]); + + const [showTimelineOut, setShowTimelineOut] = useLocalStorage('netmap-tl-out', true); + const [showTimelineIn, setShowTimelineIn] = useLocalStorage('netmap-tl-in', true); + const [timelineHover, setTimelineHover] = useState(null); // bucket index + + const exportCSV = useCallback(() => { + const rows = [['timestamp','device','type','remote_ip','country','city','bytes','packets']]; + visibleFlows.forEach(f => rows.push([ + new Date(f.timestamp).toISOString(), f.device_name||'', f.type||'', + f.remote_ip||'', f.remote_geo?.country||'', f.remote_geo?.city||'', + f.bytes||0, f.packets||0, + ])); + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([rows.map(r=>r.join(',')).join('\n')],{type:'text/csv'})); + a.download = `netmap-${new Date().toISOString().slice(0,19)}.csv`; + a.click(); + }, [visibleFlows]); + + + return ( +
+ + {/* Background */} +
+ + {/* ── SVG MAP ── */} + + + + {glowEnabled && <> + + + + + + + + + + + + + } + + + {/* Grid */} + + {[-60,-30,0,30,60].map(lat => { const pts=Array.from({length:361},(_,i)=>projection([i-180,lat])).filter(Boolean); return p.join(',')).join(' ')} fill="none" strokeWidth="0.5" style={{vectorEffect:'non-scaling-stroke'}}/>; })} + {[-150,-120,-90,-60,-30,0,30,60,90,120,150].map(lon => { const pts=Array.from({length:181},(_,i)=>projection([lon,i-90])).filter(Boolean); return p.join(',')).join(' ')} fill="none" strokeWidth="0.5" style={{vectorEffect:'non-scaling-stroke'}}/>; })} + + + {/* Countries — heatmap fill when enabled, hover highlight always */} + {worldData?.countries.features.map(f => { + const alpha2 = NUM_TO_ALPHA2[parseInt(f.id)]; + const isHovered = hoveredCountry === f.id; + const isSelected = alpha2 && selectedCountry === alpha2; + const heatFill = showHeatmap ? (countryFillColor(f.id) || theme.land) : theme.land; + const fill = isSelected ? 'rgba(56,189,248,0.28)' + : isHovered ? 'rgba(56,189,248,0.14)' + : heatFill; + return ( + setHoveredCountry(f.id)} + onMouseLeave={() => setHoveredCountry(null)} + onClick={e => { + e.stopPropagation(); + if (!alpha2) return; + setSelectedCountry(prev => prev === alpha2 ? null : alpha2); + setSelectedDevice(null); + if (!openPanels.includes('devices')) togglePanel('devices'); + }} + /> + ); + })} + {worldData && } + {worldData && } + + {/* Night overlay */} + {showNight && nightPath && } + + {/* CrowdSec ban markers */} + {crowdsecEnabled && showCrowdsec && crowdsecBans.map(ban => { + const p = ban.geo ? proj(ban.geo.lon,ban.geo.lat) : null; if (!p) return null; + const [bx,by]=p, r=5*invScale; + return ( + {e.stopPropagation();setSelectedBan(ban);}}> + {ban.isNew && } + + + + + ); + })} + + {/* Arcs */} + {displayArcs.map(a => { + const opacity = Math.max(0.06, 1-a.age*0.9); + const isSelected = selectedArc?.id === a.id; + const sw = invScale; + const coreW = (isSelected ? a.thickness*2 : a.thickness)*sw; + // Animated dot travel distance = length of path (approximated) + const dotDur = `${1.2 + (1 - a.age) * 0.8}s`; + return ( + handleArcClick(a,e)} style={{cursor:'pointer'}}> + {glowEnabled && } + + {/* Animated direction dot */} + {showArcAnim && opacity > 0.2 && ( + + + + )} + + + ); + })} + + {/* Remote dots */} + {remoteDots.map((a,i) => ( + + + + {a.remote_geo?.city && {a.remote_geo.city}} + + ))} + + {/* Home */} + {homeXY && ( + + + + + + + + {home.name} + + )} + + + {/* Zoom controls */} +
+ + + +
+ + {/* ── CLOCK ── */} +
+ {clockTime} + {clockDate} +
+ + {/* ── TOPBAR ── */} +
+
+
NetMap
+
+ {connected?'Live':'Reconnecting…'} +
+
+
+ + + + + {crowdsecEnabled && } +
+
+ setShowOutbound(v=>!v)}/> + setShowInbound(v=>!v)}/> + setShowNight(v=>!v)}/> + {crowdsecEnabled && showCrowdsec && ( +
+ + + +
+ )} + {crowdsecEnabled && setShowCrowdsec(v=>!v)}/>} + setGlowEnabled(v=>!v)}/> + setShowArcAnim(v=>!v)}/> + setShowPicker(v=>!v)}/> +
+
+ + {/* ── LEFT DOCK ── */} +
+ {[ + {id:'devices', label:'Devices', icon:'⬡'}, + {id:'flowlog', label:'Flow Log', icon:'≋'}, + {id:'talkers', label:'Top Talkers',icon:'⬆↓'}, + {id:'proto', label:'Protocols', icon:'⬢'}, + {id:'groups', label:'Groups', icon:'⊞'}, + ].map(p => ( + + ))} +
+ +
+ +
+ + {/* ── FLOATING PANELS ── */} + + {/* Devices panel */} + {openPanels.includes('devices') && ( + togglePanel('devices')} defaultPos={{x:70,y:74}}> +
+ + setSearch(e.target.value)} spellCheck={false}/> + {search && } +
+
+ + {filteredDevices.length===0&&search&&
No match for "{search}"
} + {filteredDevices.map(d=>( + + ))} +
+
+ )} + + {/* Flow log panel */} + {openPanels.includes('flowlog') && ( + ⬇ CSV} + onClose={()=>togglePanel('flowlog')} defaultPos={{x:354,y:74}}> +
+ {visibleFlows.length===0&&
No flows yet
} + {visibleFlows.map(f=>( +
{ setSelectedArc(f); setAsnInfo(''); if(f.remote_ip) lookupASN(f.remote_ip); }} style={{cursor:'pointer'}}> + + {f.blocked?'⊘':f.type==='outbound'?'↑':'↓'} + +
+ {f.device_name} + {f.remote_geo&&{f.remote_geo.city?`${f.remote_geo.city}, `:''}{f.remote_geo.country}} + {f.remote_ip&&{f.remote_ip}} +
+ {formatBytes(f.bytes)} +
+ ))} +
+
+ )} + + {/* Top talkers panel */} + {openPanels.includes('talkers') && ( + 60s} + onClose={()=>togglePanel('talkers')} defaultPos={{x:638,y:74}}> +
+ {topTalkers.length===0&&
No traffic yet
} + {topTalkers.map((t,i)=>{ + const pct=Math.round((t.total/topTalkers[0].total)*100); + return ( +
+ {i+1} +
+ {t.name} +
+
+
+ ↑{formatBytes(t.out)} + ↓{formatBytes(t.inc)} +
+
+ ); + })} +
+ + )} + + {/* Protocol breakdown panel */} + {openPanels.includes('proto') && ( + 5 min} + onClose={()=>togglePanel('proto')} defaultPos={{x:70,y:300}}> +
+ {protoStats.length===0&&
No protocol data yet
} + {protoStats.map(p=>( +
+ {protoName(p.proto)} +
+
+
+
+ {p.pct.toFixed(1)}% + {formatBytes(p.bytes)} + {p.count} flows +
+
+ ))} +
+ + )} + + {/* Groups panel */} + {openPanels.includes('groups') && ( + togglePanel('groups')} defaultPos={{x:70,y:400}}> +
+ {/* Group filter buttons */} +
+ + {DEVICE_GROUPS.map(g => ( + + ))} +
+
+ {activeDevices.map(d => { + const grp = deviceGroups[d.mac] || ''; + return ( +
+ {deviceIcon(d)} +
+ {d.name||d.mac} + {d.ip} +
+ +
+ ); + })} +
+
+
+ )} + + {/* Traffic timeline */} +
e.stopPropagation()}> +
+ Traffic — last 1h +
+ {showTimelineOut && ( + + ↑ {formatBytes(timeline.totalOut)} + + )} + {showTimelineIn && ( + + ↓ {formatBytes(timeline.totalInc)} + + )} +
+
+ + +
+
+
setTimelineHover(null)} + onMouseMove={e=>{ + const rect = e.currentTarget.getBoundingClientRect(); + const pct = (e.clientX - rect.left) / rect.width; + setTimelineHover(Math.min(TIMELINE_BUCKETS-1, Math.floor(pct*TIMELINE_BUCKETS))); + }}> + + {showTimelineOut && timeline.out.map((v,i) => { + const h = (v/timeline.maxVal)*34; + return ; + })} + {showTimelineIn && timeline.inc.map((v,i) => { + const h = (v/timeline.maxVal)*34; + return ; + })} + + {timelineHover !== null && (() => { + const leftPct = (timelineHover + 0.5) / TIMELINE_BUCKETS * 100; + const minsAgo = TIMELINE_BUCKETS - timelineHover; + // Clamp tooltip so it stays on screen: left edge at 2%, right edge at 98% + const clampedLeft = Math.max(2, Math.min(98, leftPct)); + // Shift transform so tooltip doesn't clip at edges + const transformX = leftPct < 10 ? '0%' : leftPct > 90 ? '-100%' : '-50%'; + return ( + <> +
+
+ {minsAgo}m ago + {showTimelineOut && ` · ↑${formatBytes(timeline.out[timelineHover])}`} + {showTimelineIn && ` · ↓${formatBytes(timeline.inc[timelineHover])}`} +
+ + ); + })()} +
+
+ + {/* Country filter badge */} + {selectedCountry && ( +
e.stopPropagation()}> + Filtered: {selectedCountry} + {countryDeviceMacs?.size||0} device{countryDeviceMacs?.size!==1?'s':''} + +
+ )} + + {/* Replay controls overlay */} + {replayMode && ( +
e.stopPropagation()}> +
+ ⏮ Replay + {replayTimestamp && {replayTimestamp.toLocaleTimeString('no-NO')}} + {replayArcs?.length||0} arcs + +
+
+ + + + {setReplayPos(Number(e.target.value));setReplayPlaying(false);}}/> + {replayPos.toFixed(0)}% +
+
+ )} + + {/* Heatmap legend */} + {showHeatmap && ( +
+
Connections (5 min)
+
+ 0 +
+ {maxCountryCount} +
+ {maxCountry && ( +
Top: {maxCountry.code} — {maxCountry.count} flows
+ )} + {hoveredCountry && (() => { + const alpha2 = NUM_TO_ALPHA2[parseInt(hoveredCountry)]; + const count = alpha2 ? (countryCounts[alpha2] || 0) : 0; + return
{alpha2||'?'} — {count} flow{count!==1?'s':''}
; + })()} +
+ )} + + {selectedArc && ( +
e.stopPropagation()}> +
+ + {selectedArc.blocked?'⊘ Blocked':selectedArc.type==='outbound'?'↑ Outbound':'↓ Inbound'} + + +
+
+ + + + + {rdnsInfo && } + + {selectedArc.proto>0 && } + {selectedArc.dst_port>0 && } + + {selectedArc.packets>0&&} +
+
+ )} + + {/* ── BAN POPUP ── */} + {selectedBan && ( +
e.stopPropagation()}> +
+ ⊘ CrowdSec Ban + +
+
+ + + + + + +
+
+ )} + + {/* ── DEVICE DETAIL ── */} + {(() => { + const d = selectedDevice ? devices.find(x=>x.mac===selectedDevice) : null; + if (!d) return null; + return ( +
+
+ {deviceIcon(d)} +
{d.name}
{d.mac}
+ +
+
+ + + + + {d.signal&&} +
+
+ ); + })()} + + {/* ── COLOR PICKER ── */} + {showPicker && ( +
e.stopPropagation()}> +
+ Map Colors + + +
+
+ updateTheme('bg',v)}/> + updateTheme('land',v)}/> + updateTheme('coast',v)}/> + updateTheme('borders',v)}/> + updateTheme('grid',v)}/> +
+
+ Presets +
+ {PRESETS.map(p=>( + + ))} +
+
+
+ )} + + {!geoipLoaded&&connected&&
⚠ GeoIP database not loaded
} + +
+ ); +} + +function Stat({label,value,color}) { + return
{label}{value}
; +} +function Toggle({label,active,color,onClick}) { + return ; +} +function DetailRow({label,value,mono,color}) { + return
{label}{value}
; +} +function ArcRow({label,value,mono}) { + return
{label}{value}
; +} +function ColorRow({label,value,onChange}) { + return ( +
+ {label} +
+ onChange(e.target.value)} className="color-native"/> + {value} +
+
+ ); +} + +function Sparkline({ data }) { + const max = Math.max(...data, 1); + const W = 60, H = 14; + const bw = W / data.length - 1; + return ( + + {data.map((v,i) => { + const h = Math.max(1, (v/max)*H); + return 0?'#38bdf855':'#ffffff10'} rx="1"/>; + })} + + ); +} diff --git a/frontend/src/DraggablePanel.jsx b/frontend/src/DraggablePanel.jsx new file mode 100644 index 0000000..3dedcfe --- /dev/null +++ b/frontend/src/DraggablePanel.jsx @@ -0,0 +1,88 @@ +import React, { useState, useRef, useCallback, useEffect } from 'react'; + +const MIN_W = 220; +const MIN_H = 160; + +export function DraggablePanel({ id, title, badge, extra, onClose, children, defaultPos }) { + const [pos, setPos] = useState(() => { + try { const s = localStorage.getItem(`panel-pos-${id}`); return s ? JSON.parse(s) : defaultPos; } + catch { return defaultPos; } + }); + const [size, setSize] = useState(() => { + try { const s = localStorage.getItem(`panel-size-${id}`); return s ? JSON.parse(s) : { w: 272, h: 480 }; } + catch { return { w: 272, h: 480 }; } + }); + + const savePos = (p) => { try { localStorage.setItem(`panel-pos-${id}`, JSON.stringify(p)); } catch {} }; + const saveSize = (s) => { try { localStorage.setItem(`panel-size-${id}`, JSON.stringify(s)); } catch {} }; + + const dragRef = useRef(null); + const resizeRef = useRef(null); + const panelRef = useRef(null); + + // ── Drag ────────────────────────────────────────────────────────────────── + const onDragMouseDown = useCallback((e) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y }; + }, [pos]); + + // ── Resize ──────────────────────────────────────────────────────────────── + const onResizeMouseDown = useCallback((e) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: size.w, origH: size.h }; + }, [size]); + + useEffect(() => { + const onMouseMove = (e) => { + if (dragRef.current) { + const d = dragRef.current; + const nx = Math.max(0, Math.min(window.innerWidth - size.w, d.origX + (e.clientX - d.startX))); + const ny = Math.max(0, Math.min(window.innerHeight - 60, d.origY + (e.clientY - d.startY))); + const p = { x: nx, y: ny }; + setPos(p); + savePos(p); + } + if (resizeRef.current) { + const d = resizeRef.current; + const nw = Math.max(MIN_W, d.origW + (e.clientX - d.startX)); + const nh = Math.max(MIN_H, d.origH + (e.clientY - d.startY)); + const s = { w: nw, h: nh }; + setSize(s); + saveSize(s); + } + }; + const onMouseUp = () => { dragRef.current = null; resizeRef.current = null; }; + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); }; + }, [size.w]); + + return ( +
e.stopPropagation()} + > + {/* Header — drag handle */} +
+ {title} + {badge != null && {badge}} + {extra} + +
+ + {/* Content */} +
+ {children} +
+ + {/* Resize handle — bottom-right corner */} +
+
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..47519df --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,400 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --border: rgba(56,189,248,0.11); + --border-h: rgba(56,189,248,0.26); + --accent-out: #38bdf8; + --accent-in: #f472b6; + --blocked: #cc2200; + --text: #dde6f0; + --text-dim: #445566; + --mono: 'JetBrains Mono', monospace; + --ui: 'Space Grotesk', sans-serif; + --green: #4ade80; + --yellow: #fbbf24; + --panel-bg: rgba(4,8,16,0.96); +} + +body { color:var(--text); font-family:var(--ui); overflow:hidden; height:100vh; width:100vw; } +::-webkit-scrollbar { width:3px; } ::-webkit-scrollbar-track { background:transparent; } ::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; } +.panel { background:var(--panel-bg); border:1px solid var(--border); border-radius:12px; backdrop-filter:blur(20px); } +button { font-family:var(--ui); cursor:pointer; border:none; outline:none; } +.mono { font-family:var(--mono); } + +/* ── Layout ── */ +.app { position:relative; width:100vw; height:100vh; overflow:hidden; } +.world-svg { position:absolute; inset:0; width:100%; height:100%; display:block; } + +/* ── Zoom ── */ +.zoom-controls { position:absolute; bottom:72px; right:20px; display:flex; flex-direction:column; gap:4px; z-index:20; } +.zoom-controls button { width:32px; height:32px; background:var(--panel-bg); border:1px solid var(--border); color:var(--text-dim); border-radius:8px; font-size:17px; display:flex; align-items:center; justify-content:center; transition:all 0.15s; } +.zoom-controls button:hover { border-color:var(--border-h); color:var(--accent-out); } +.zoom-reset { font-size:12px !important; } + +/* ── Clock ── */ +.clock { position:absolute; top:14px; left:14px; padding:8px 14px; z-index:20; display:flex; flex-direction:column; gap:2px; } +.clock-time { font-size:18px; font-weight:600; letter-spacing:0.05em; } +.clock-date { font-size:10px; color:var(--text-dim); } + +/* ── Topbar ── */ +.topbar { position:absolute; top:14px; left:50%; transform:translateX(-50%); display:flex; align-items:center; gap:20px; padding:9px 18px; white-space:nowrap; z-index:20; } +.topbar-left { display:flex; align-items:center; gap:12px; } +.logo { display:flex; align-items:center; gap:7px; } +.logo-icon { font-size:16px; color:var(--accent-out); filter:drop-shadow(0 0 5px var(--accent-out)); } +.logo-text { font-size:14px; font-weight:600; letter-spacing:0.08em; } +.conn-badge { display:flex; align-items:center; gap:5px; font-size:10px; font-weight:500; padding:2px 8px; border-radius:20px; } +.conn-ok { background:rgba(74,222,128,0.08); color:var(--green); border:1px solid rgba(74,222,128,0.2); } +.conn-err { background:rgba(248,113,113,0.08); color:#f87171; border:1px solid rgba(248,113,113,0.2); } +.conn-dot { width:5px; height:5px; border-radius:50%; background:currentColor; animation:pulse 2s infinite; } +.topbar-stats { display:flex; gap:16px; } +.stat { display:flex; flex-direction:column; align-items:center; gap:1px; } +.stat-label { font-size:8px; font-weight:500; letter-spacing:0.1em; color:var(--text-dim); text-transform:uppercase; } +.stat-value { font-size:12px; font-weight:600; } +.topbar-toggles { display:flex; gap:6px; align-items:center; } +.toggle-btn { padding:4px 11px; border-radius:20px; font-size:11px; font-weight:500; background:rgba(255,255,255,0.04); border:1px solid var(--border); color:var(--text-dim); transition:all 0.2s; } +.toggle-btn:hover { color:var(--text); } +.toggle-active { background:rgba(255,255,255,0.07) !important; } +.origin-toggle { display:flex; border-radius:20px; overflow:hidden; border:1px solid var(--border); } +.origin-toggle button { padding:4px 9px; font-size:10px; font-weight:500; background:rgba(255,255,255,0.04); color:var(--text-dim); border:none; border-left:1px solid var(--border); transition:all 0.15s; } +.origin-toggle button:first-child { border-left:none; } +.origin-toggle button:hover { color:var(--text); } +.origin-toggle button.active { background:rgba(204,34,0,0.15); color:#ff6666; } +.origin-count { display:inline-block; font-size:8px; font-family:var(--mono); background:rgba(255,255,255,0.1); border-radius:8px; padding:0 4px; margin-left:3px; } + +/* ── Left dock ── */ +.left-dock { + position:absolute; left:0; top:50%; transform:translateY(-50%); + display:flex; flex-direction:column; gap:2px; + background:var(--panel-bg); border:1px solid var(--border); + border-left:none; border-radius:0 12px 12px 0; + padding:6px 4px; z-index:20; +} +.dock-btn { + display:flex; flex-direction:column; align-items:center; gap:3px; + padding:10px 10px; border-radius:8px; background:transparent; + color:var(--text-dim); transition:all 0.15s; min-width:54px; + border:1px solid transparent; position:relative; +} +.dock-btn:hover { background:rgba(255,255,255,0.05); color:var(--text); } +.dock-btn.dock-active { background:rgba(56,189,248,0.08); color:var(--accent-out); border-color:rgba(56,189,248,0.2); } +.dock-icon { font-size:16px; line-height:1; } +.dock-label { font-size:9px; font-weight:500; letter-spacing:0.04em; white-space:nowrap; } +.dock-count { position:absolute; top:5px; right:5px; font-size:8px; font-family:var(--mono); background:rgba(56,189,248,0.2); color:var(--accent-out); border-radius:8px; padding:0 4px; } + +/* ── Panel stack ── */ +.panel-stack { + position:absolute; left:70px; top:74px; + display:flex; flex-direction:row; gap:8px; + height:calc(100vh - 88px); z-index:20; + pointer-events:none; +} +.side-panel { + width:272px; height:100%; display:flex; flex-direction:column; + overflow:hidden; pointer-events:all; animation:fadeIn 0.15s ease; + flex-shrink:0; +} +.side-panel-header { + display:flex; align-items:center; gap:8px; padding:10px 12px; + border-bottom:1px solid var(--border); flex-shrink:0; + font-size:12px; font-weight:600; +} +.side-panel-header .tab-count { + background:rgba(56,189,248,0.1); color:var(--accent-out); + font-size:9px; padding:1px 5px; border-radius:8px; font-family:var(--mono); +} +.side-panel-header .close-btn { margin-left:auto; } + +/* ── Search ── */ +.search-wrap { display:flex; align-items:center; gap:6px; margin:7px 8px 3px; padding:6px 9px; background:rgba(255,255,255,0.03); border:1px solid var(--border); border-radius:7px; flex-shrink:0; transition:border-color 0.15s; } +.search-wrap:focus-within { border-color:var(--border-h); } +.search-icon { font-size:13px; color:var(--text-dim); flex-shrink:0; } +.search-input { flex:1; background:none; border:none; outline:none; font-family:var(--ui); font-size:11px; color:var(--text); } +.search-input::placeholder { color:var(--text-dim); } +.search-clear { background:none; color:var(--text-dim); font-size:10px; } +.search-clear:hover { color:var(--text); } + +/* ── Device list ── */ +.device-list { overflow-y:auto; flex:1; padding:3px 8px 8px; display:flex; flex-direction:column; gap:2px; } +.device-item { display:flex; align-items:center; gap:8px; padding:7px 8px; border-radius:7px; background:transparent; border:1px solid transparent; color:var(--text); text-align:left; width:100%; transition:all 0.12s; } +.device-item:hover { background:rgba(255,255,255,0.03); border-color:var(--border); } +.device-item.selected { background:rgba(56,189,248,0.07); border-color:rgba(56,189,248,0.28); } +.all-devices { border-style:dashed; border-color:var(--border); margin-bottom:3px; } +.all-devices.selected { border-style:solid; } +.device-icon { font-size:15px; flex-shrink:0; } +.device-info { flex:1; min-width:0; display:flex; flex-direction:column; gap:1px; } +.device-name { font-size:11px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.device-sub { font-size:9px; color:var(--text-dim); } +.device-traffic { display:flex; flex-direction:column; align-items:flex-end; gap:1px; font-size:9px; font-family:var(--mono); flex-shrink:0; } + +/* ── Flow log ── */ +.flow-log { overflow-y:auto; flex:1; padding:5px 8px; display:flex; flex-direction:column; gap:2px; } +.flow-entry { display:flex; align-items:center; gap:7px; padding:5px 7px; border-radius:6px; background:rgba(255,255,255,0.02); border:1px solid transparent; font-size:11px; transition:background 0.1s; } +.flow-entry:hover { background:rgba(255,255,255,0.05); } +.flow-entry.flow-blocked { border-color:rgba(204,34,0,0.2); background:rgba(204,34,0,0.05); } +.flow-dir { font-size:12px; font-weight:700; flex-shrink:0; width:13px; text-align:center; } +.flow-dir.out { color:var(--accent-out); } +.flow-dir.in { color:var(--accent-in); } +.flow-dir.blocked { color:var(--blocked); } +.flow-detail { flex:1; min-width:0; display:flex; flex-direction:column; gap:1px; } +.flow-device { font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:11px; } +.flow-dest { font-size:9px; color:var(--text-dim); } +.flow-ip { font-size:8px; color:var(--text-dim); opacity:0.6; } +.flow-bytes { font-size:9px; color:var(--text-dim); flex-shrink:0; font-family:var(--mono); } + +/* ── Top talkers ── */ +.talkers-sub { font-size:9px; color:var(--text-dim); font-weight:400; } +.talker-list { padding:8px; display:flex; flex-direction:column; gap:8px; overflow-y:auto; flex:1; } +.talker-row { display:flex; align-items:center; gap:8px; } +.talker-rank { font-size:10px; color:var(--text-dim); font-family:var(--mono); width:12px; flex-shrink:0; } +.talker-info { flex:1; min-width:0; } +.talker-name { font-size:11px; font-weight:500; display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:4px; } +.talker-bar-wrap { height:2px; background:rgba(255,255,255,0.06); border-radius:1px; } +.talker-bar { height:100%; background:var(--accent-out); border-radius:1px; transition:width 0.5s ease; } +.talker-bytes { display:flex; flex-direction:column; align-items:flex-end; gap:1px; font-size:9px; font-family:var(--mono); flex-shrink:0; } + +/* ── Arc popup ── */ +.arc-popup { position:absolute; top:74px; right:14px; width:240px; padding:13px; z-index:30; animation:fadeIn 0.15s ease; } +.arc-popup-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:11px; } +.arc-popup-dir { font-size:11px; font-weight:600; letter-spacing:0.05em; } +.arc-row { display:flex; justify-content:space-between; align-items:baseline; padding:4px 0; border-bottom:1px solid rgba(255,255,255,0.04); font-size:11px; } +.arc-row:last-child { border-bottom:none; } +.arc-label { color:var(--text-dim); flex-shrink:0; margin-right:8px; } +.arc-value { font-weight:500; text-align:right; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:160px; } + +/* ── Device detail ── */ +.device-detail { position:absolute; bottom:14px; right:14px; width:230px; padding:13px; z-index:20; animation:fadeIn 0.15s ease; } +.detail-header { display:flex; align-items:center; gap:9px; margin-bottom:10px; } +.detail-icon { font-size:20px; } +.detail-name { font-size:12px; font-weight:600; } +.detail-mac { font-size:9px; color:var(--text-dim); font-family:var(--mono); } +.detail-grid { display:flex; flex-direction:column; gap:5px; } +.detail-row { display:flex; justify-content:space-between; align-items:center; font-size:11px; } +.detail-label { color:var(--text-dim); } +.detail-value { font-weight:500; } + +/* ── Shared close btn ── */ +.close-btn { background:rgba(255,255,255,0.04); border:1px solid var(--border); color:var(--text-dim); border-radius:5px; width:21px; height:21px; font-size:9px; display:flex; align-items:center; justify-content:center; transition:all 0.12s; flex-shrink:0; } +.close-btn:hover { color:var(--text); } +/* ── Color picker ── */ +.color-picker { position:absolute; bottom:60px; right:14px; width:240px; padding:14px; z-index:30; animation:fadeIn 0.15s ease; } +.picker-header { display:flex; align-items:center; gap:8px; margin-bottom:12px; } +.picker-title { font-size:12px; font-weight:600; flex:1; } +.picker-reset { font-size:10px; padding:3px 8px; border-radius:6px; background:rgba(255,255,255,0.06); border:1px solid var(--border); color:var(--text-dim); } +.picker-reset:hover { color:var(--text); } +.picker-rows { display:flex; flex-direction:column; gap:8px; margin-bottom:14px; } +.color-row { display:flex; align-items:center; justify-content:space-between; } +.color-label { font-size:11px; color:var(--text-dim); } +.color-input-wrap { display:flex; align-items:center; gap:7px; } +.color-native { width:28px; height:22px; padding:0; border:1px solid var(--border); border-radius:5px; background:none; cursor:pointer; } +.color-native::-webkit-color-swatch-wrapper { padding:2px; } +.color-native::-webkit-color-swatch { border:none; border-radius:3px; } +.color-hex { font-size:10px; color:var(--text-dim); width:54px; } +.picker-preset-label { font-size:9px; text-transform:uppercase; letter-spacing:0.1em; color:var(--text-dim); display:block; margin-bottom:7px; } +.preset-btns { display:flex; flex-wrap:wrap; gap:5px; } +.preset-btn { display:flex; align-items:center; gap:5px; padding:4px 9px; border-radius:6px; font-size:10px; background:rgba(255,255,255,0.04); border:1px solid var(--border); color:var(--text-dim); transition:all 0.12s; } +.preset-btn:hover { color:var(--text); border-color:var(--border-h); } +.preset-swatch { width:8px; height:8px; border-radius:50%; flex-shrink:0; } + +/* ── Floating draggable panels ── */ +.floating-panel { + position: absolute; + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 25; + animation: fadeIn 0.15s ease; + min-width: 220px; + min-height: 160px; +} + +.fp-header { + display: flex; + align-items: center; + gap: 6px; + padding: 9px 12px; + border-bottom: 1px solid var(--border); + cursor: grab; + flex-shrink: 0; + user-select: none; +} +.fp-header:active { cursor: grabbing; } + +.fp-title { + font-size: 12px; + font-weight: 600; + flex: 1; /* takes all space, pushes everything else right */ +} + +/* badge / sub-label next to title */ +.fp-header .tab-count, +.fp-header .talkers-sub { + flex-shrink: 0; +} + +/* export button — same height as close btn */ +.export-btn-sm { + font-size: 9px; + padding: 0 8px; + height: 21px; + line-height: 1; + border-radius: 5px; + background: rgba(255,255,255,0.05); + border: 1px solid var(--border); + color: var(--text-dim); + transition: all 0.15s; + flex-shrink: 0; + display: flex; + align-items: center; +} +.export-btn-sm:hover { color: var(--text); } + +.fp-body { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.fp-resize { + position: absolute; + bottom: 2px; + right: 4px; + font-size: 14px; + color: var(--text-dim); + cursor: se-resize; + opacity: 0.4; + line-height: 1; + user-select: none; +} +.fp-resize:hover { opacity: 0.9; } + +/* fp-body children that need to scroll */ +.fp-body .device-list, +.fp-body .flow-log, +.fp-body .talker-list { + flex: 1; + overflow-y: auto; +} + +/* search inside floating panel */ +.fp-body .search-wrap { flex-shrink: 0; } + +/* ── Protocol breakdown ── */ +.proto-list { padding:10px 12px; display:flex; flex-direction:column; gap:10px; overflow-y:auto; flex:1; } +.proto-row { display:flex; flex-direction:column; gap:4px; } +.proto-name { font-size:12px; font-weight:600; } +.proto-bar-wrap { height:4px; background:rgba(255,255,255,0.06); border-radius:2px; } +.proto-bar { height:100%; border-radius:2px; transition:width 0.4s ease; } +.proto-stats { display:flex; gap:10px; font-size:10px; color:var(--text-dim); } +.proto-pct { min-width:36px; } +.proto-bytes { font-family:var(--mono); } +.proto-count { font-family:var(--mono); opacity:0.6; } + +/* ── Replay bar ── */ +.replay-bar { + position:absolute; bottom:20px; left:50%; transform:translateX(-50%); + padding:10px 16px; z-index:30; min-width:480px; + animation:fadeIn 0.15s ease; +} +.replay-header { display:flex; align-items:center; gap:12px; margin-bottom:8px; font-size:11px; } +.replay-label { font-weight:600; color:var(--accent-out); } +.replay-time { color:var(--text); } +.replay-count { color:var(--text-dim); flex:1; } +.replay-live { padding:3px 10px; border-radius:6px; background:rgba(56,189,248,0.1); border:1px solid rgba(56,189,248,0.3); color:var(--accent-out); font-size:10px; } +.replay-live:hover { background:rgba(56,189,248,0.2); } +.replay-controls { display:flex; align-items:center; gap:8px; } +.replay-btn { width:28px; height:28px; border-radius:6px; background:rgba(255,255,255,0.06); border:1px solid var(--border); color:var(--text); font-size:13px; display:flex; align-items:center; justify-content:center; flex-shrink:0; } +.replay-btn:hover { border-color:var(--border-h); color:var(--accent-out); } +.replay-slider { flex:1; accent-color:var(--accent-out); cursor:pointer; } +.replay-pos { font-size:10px; color:var(--text-dim); min-width:32px; text-align:right; } + +/* ── Dock divider ── */ +.dock-divider { height:1px; background:var(--border); margin:4px 6px; } + +/* ── Heatmap legend ── */ +.heatmap-legend { + position:absolute; bottom:20px; right:70px; + padding:10px 14px; z-index:20; min-width:180px; + animation:fadeIn 0.15s ease; +} +.heatmap-title { font-size:10px; font-weight:600; color:var(--text-dim); text-transform:uppercase; letter-spacing:0.08em; margin-bottom:8px; } +.heatmap-scale { display:flex; align-items:center; gap:8px; margin-bottom:6px; } +.heatmap-min, .heatmap-max { font-size:10px; font-family:var(--mono); color:var(--text-dim); flex-shrink:0; } +.heatmap-gradient { + flex:1; height:6px; border-radius:3px; + background: linear-gradient(to right, rgba(0,40,60,0.4), rgba(0,130,150,0.6), rgba(0,200,220,0.85)); +} +.heatmap-top { font-size:10px; color:var(--text-dim); } +.heatmap-top strong { color:var(--accent-out); } +.heatmap-hover { font-size:10px; color:var(--text); margin-top:4px; font-family:var(--mono); } + +/* ── Traffic timeline ── */ +.timeline { + position:absolute; bottom:0; left:0; right:0; + padding:5px 14px 5px; z-index:15; + border-radius:0; border-left:none; border-right:none; border-bottom:none; + background:rgba(4,8,16,0.92); + user-select:none; +} +.timeline-header { + display:flex; align-items:center; gap:12px; margin-bottom:3px; +} +.timeline-label { font-size:9px; color:var(--text-dim); text-transform:uppercase; letter-spacing:0.08em; flex:1; } +.timeline-stats { display:flex; gap:12px; font-size:9px; font-family:var(--mono); } +.timeline-stat-out { color:var(--accent-out); } +.timeline-stat-in { color:var(--accent-in); } +.timeline-toggles { display:flex; gap:4px; } +.timeline-toggle { + padding:1px 7px; border-radius:10px; font-size:9px; font-weight:500; + background:rgba(255,255,255,0.04); border:1px solid var(--border); + color:var(--text-dim); transition:all 0.15s; cursor:pointer; +} +.timeline-toggle:hover { color:var(--text); } +.timeline-toggle.on-out { background:rgba(56,189,248,0.1); color:var(--accent-out); border-color:rgba(56,189,248,0.3); } +.timeline-toggle.on-in { background:rgba(244,114,182,0.1); color:var(--accent-in); border-color:rgba(244,114,182,0.3); } +.timeline-wrap { position:relative; } +.timeline-svg { display:block; width:100%; cursor:crosshair; } +.timeline-tooltip { + position:absolute; top:-36px; transform:translateX(-50%); + background:var(--panel-bg); border:1px solid var(--border); + padding:3px 8px; border-radius:6px; font-size:9px; font-family:var(--mono); + white-space:nowrap; pointer-events:none; z-index:5; +} +.timeline-cursor { position:absolute; top:0; bottom:0; width:1px; background:rgba(255,255,255,0.3); pointer-events:none; } + +/* ── Country filter badge ── */ +.country-badge { + position:absolute; top:74px; left:50%; transform:translateX(-50%); + display:flex; align-items:center; gap:10px; + padding:6px 12px; z-index:25; font-size:11px; + animation:fadeIn 0.15s ease; +} +.country-badge strong { color:var(--accent-out); } +.country-badge-count { color:var(--text-dim); font-size:10px; } + +/* ── Device group dot ── */ +.group-dot { display:inline-block; width:6px; height:6px; border-radius:50%; margin-right:5px; vertical-align:middle; flex-shrink:0; } + +/* ── Groups panel ── */ +.groups-body { display:flex; flex-direction:column; flex:1; overflow:hidden; } +.group-filters { display:flex; flex-wrap:wrap; gap:4px; padding:8px; flex-shrink:0; border-bottom:1px solid var(--border); } +.group-filter-btn { padding:3px 10px; border-radius:20px; font-size:10px; font-weight:500; background:rgba(255,255,255,0.04); border:1px solid var(--border); color:var(--text-dim); transition:all 0.15s; } +.group-filter-btn:hover { color:var(--text); } +.group-filter-btn.active { background:rgba(56,189,248,0.1); color:var(--accent-out); border-color:rgba(56,189,248,0.3); } +.group-count { font-size:8px; font-family:var(--mono); background:rgba(255,255,255,0.1); border-radius:8px; padding:0 4px; margin-left:3px; } + +.group-device-list { overflow-y:auto; flex:1; padding:4px 8px; display:flex; flex-direction:column; gap:3px; } +.group-device-row { display:flex; align-items:center; gap:8px; padding:5px 6px; border-radius:6px; transition:background 0.1s; } +.group-device-row:hover { background:rgba(255,255,255,0.03); } +.group-device-info { flex:1; min-width:0; display:flex; flex-direction:column; gap:1px; } +.group-select { + background:rgba(255,255,255,0.06); border:1px solid var(--border); + color:var(--text-dim); border-radius:5px; font-size:9px; padding:2px 4px; + cursor:pointer; flex-shrink:0; font-family:var(--ui); +} +.group-select:hover { border-color:var(--border-h); } +.group-select option { background:#0d1829; } + +/* ── Misc ── */ +.geoip-warning { position:absolute; bottom:14px; left:50%; transform:translateX(-50%); padding:7px 14px; font-size:11px; color:var(--yellow); border-color:rgba(251,191,36,0.15); z-index:20; white-space:nowrap; } +.empty-state { color:var(--text-dim); font-size:11px; text-align:center; padding:24px 12px; } diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx new file mode 100644 index 0000000..9a55721 --- /dev/null +++ b/frontend/src/index.jsx @@ -0,0 +1,7 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.jsx'; +import './index.css'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(); diff --git a/frontend/src/useLocalStorage.js b/frontend/src/useLocalStorage.js new file mode 100644 index 0000000..c042e7c --- /dev/null +++ b/frontend/src/useLocalStorage.js @@ -0,0 +1,22 @@ +import { useState, useCallback } from 'react'; + +export function useLocalStorage(key, defaultValue) { + const [value, setValueState] = useState(() => { + try { + const stored = localStorage.getItem(key); + return stored !== null ? JSON.parse(stored) : defaultValue; + } catch { + return defaultValue; + } + }); + + const setValue = useCallback((newVal) => { + setValueState(prev => { + const next = typeof newVal === 'function' ? newVal(prev) : newVal; + try { localStorage.setItem(key, JSON.stringify(next)); } catch {} + return next; + }); + }, [key]); + + return [value, setValue]; +} diff --git a/frontend/src/useNetMapSocket.js b/frontend/src/useNetMapSocket.js new file mode 100644 index 0000000..762c9b0 --- /dev/null +++ b/frontend/src/useNetMapSocket.js @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; + +const WS_URL = import.meta.env.VITE_WS_URL || + `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ws`; + +export function useNetMapSocket() { + const [connected, setConnected] = useState(false); + const [devices, setDevices] = useState([]); + const [flows, setFlows] = useState([]); + const [home, setHome] = useState({ lat: 59.9139, lon: 10.7522, name: 'Home' }); + const [geoipLoaded, setGeoipLoaded] = useState(true); + const wsRef = useRef(null); + const reconnectTimer = useRef(null); + const flowIdSet = useRef(new Set()); + + const addFlows = useCallback((newFlows) => { + const fresh = newFlows.filter(f => !flowIdSet.current.has(f.id)); + fresh.forEach(f => flowIdSet.current.add(f.id)); + + // Keep flowId set from growing unbounded + if (flowIdSet.current.size > 2000) { + const arr = [...flowIdSet.current]; + arr.slice(0, 500).forEach(id => flowIdSet.current.delete(id)); + } + + if (fresh.length === 0) return; + + setFlows(prev => { + const combined = [...prev, ...fresh]; + // Keep last 300 flows in state + return combined.slice(-300); + }); + }, []); + + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) return; + + const ws = new WebSocket(WS_URL); + wsRef.current = ws; + + ws.onopen = () => { + setConnected(true); + if (reconnectTimer.current) clearTimeout(reconnectTimer.current); + }; + + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + if (msg.type === 'init') { + setHome(msg.home); + setDevices(msg.devices || []); + setGeoipLoaded(msg.geoip_loaded !== false); + if (msg.recent_flows) addFlows(msg.recent_flows); + } else if (msg.type === 'devices') { + setDevices(msg.devices || []); + } else if (msg.type === 'flows') { + addFlows(msg.flows || []); + } + } catch (_) { + } + }; + + ws.onclose = () => { + setConnected(false); + reconnectTimer.current = setTimeout(connect, 3000); + }; + + ws.onerror = () => ws.close(); + }, [addFlows]); + + useEffect(() => { + connect(); + return () => { + if (reconnectTimer.current) clearTimeout(reconnectTimer.current); + wsRef.current?.close(); + }; + }, [connect]); + + return { connected, devices, flows, home, geoipLoaded }; +} diff --git a/frontend/src/utils.js b/frontend/src/utils.js new file mode 100644 index 0000000..9747a67 --- /dev/null +++ b/frontend/src/utils.js @@ -0,0 +1,87 @@ +export function formatBytes(bytes) { + if (!bytes || bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; +} + +export function formatUptime(seconds) { + if (!seconds) return '—'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +export function protoName(n) { + const PROTOS = { 1:'ICMP', 2:'IGMP', 6:'TCP', 17:'UDP', 41:'IPv6', 47:'GRE', 50:'ESP', 51:'AH', 58:'ICMPv6', 89:'OSPF', 132:'SCTP' }; + return PROTOS[n] || (n ? `IP/${n}` : '—'); +} + +export function protoColor(n) { + const COLORS = { 6:'#38bdf8', 17:'#a78bfa', 1:'#fbbf24', 58:'#fb923c' }; + return COLORS[n] || '#64748b'; +} + +export function serviceLabel(port, proto) { + const TCP = { + 20:'FTP-data', 21:'FTP', 22:'SSH', 23:'Telnet', 25:'SMTP', 53:'DNS', + 80:'HTTP', 110:'POP3', 143:'IMAP', 443:'HTTPS', 465:'SMTPS', 587:'SMTP', + 993:'IMAPS', 995:'POP3S', 1194:'OpenVPN', 1883:'MQTT', 3306:'MySQL', + 3389:'RDP', 5900:'VNC', 6443:'K8s API', 8080:'HTTP-alt', 8443:'HTTPS-alt', + 8883:'MQTT-TLS', 9000:'Custom', 51820:'WireGuard', + }; + const UDP = { + 53:'DNS', 67:'DHCP', 68:'DHCP', 123:'NTP', 161:'SNMP', 500:'IKE', + 514:'Syslog', 1194:'OpenVPN', 4500:'IPsec', 5353:'mDNS', 51820:'WireGuard', + }; + if (proto === 6 && TCP[port]) return TCP[port]; + if (proto === 17 && UDP[port]) return UDP[port]; + if (TCP[port]) return TCP[port]; + return port ? `port ${port}` : ''; +} + +export const DEVICE_GROUPS = ['IoT', 'Servers', 'Mobile', 'Network', 'Desktop', 'Media']; +export const GROUP_COLORS = { + IoT:'#f59e0b', Servers:'#3b82f6', Mobile:'#ec4899', + Network:'#10b981', Desktop:'#8b5cf6', Media:'#f97316', +}; + +export function timeAgo(ms) { + if (!ms) return '—'; + const sec = Math.floor((Date.now() - ms) / 1000); + if (sec < 10) return 'just now'; + if (sec < 60) return `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + return `${Math.floor(min / 60)}h ago`; +} + +export function deviceIcon(device) { + if (device.is_wired) return '🖥'; + const oui = (device.oui || '').toLowerCase(); + if (oui.includes('apple')) return '🍎'; + if (oui.includes('samsung')) return '📱'; + if (oui.includes('sony')) return '🎮'; + if (oui.includes('lg')) return '📺'; + if (oui.includes('intel')) return '💻'; + if (oui.includes('raspberry')) return '🍓'; + return '📡'; +} + +// Map app/category codes to readable names +const APP_NAMES = { + 0: 'Other', 1: 'Instant messaging', 2: 'P2P', 3: 'Thumbnails', + 4: 'Streaming media', 5: 'VoIP', 6: 'Gaming', 7: 'Remote access', + 8: 'News feeds', 9: 'Streaming video', 10: 'Email', 11: 'News', + 12: 'Downloads', 13: 'Business apps', 14: 'Social networking', + 15: 'Web browsing', 255: 'Unknown', +}; + +export function appName(cat) { + if (typeof cat === 'number') return APP_NAMES[cat] || `Cat ${cat}`; + return cat || 'Unknown'; +} + +export const OUT_COLOR = '#38bdf8'; +export const IN_COLOR = '#f472b6'; diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..1417a25 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'build', + }, + server: { + port: 3000, + }, + esbuild: { + loader: 'jsx', + include: /src\/.*\.[jt]sx?$/, + }, +})