Compare commits
2 Commits
1b3773ea13
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ae062a04ed | |||
| 5f8a43e652 |
+21
@@ -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
|
||||
@@ -1,3 +1,293 @@
|
||||
# webapps
|
||||
# MorkNetVisualizer
|
||||
|
||||
Personal web applications
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
[Download v1.0 tar.gz](https://github.com/danielmorkcode/MorkNetVisualizer/releases/download/v1.0.0/netmapFinal.tar.gz)
|
||||
|
||||
Or clone the repository and build from source.
|
||||
|
||||
---
|
||||
|
||||
## 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 <your-repo-url>
|
||||
cd MorkNetVisualizer
|
||||
```
|
||||
|
||||
### 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://<host-ip>: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, MorkNetVisualizer 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 MorkNetVisualizer 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 MorkNetVisualizer-reader
|
||||
```
|
||||
|
||||
Copy the generated key. Then in `backend/.env`:
|
||||
|
||||
```
|
||||
CROWDSEC_URL=http://<crowdsec-host-ip>:8080
|
||||
CROWDSEC_API_KEY=<key from above>
|
||||
```
|
||||
|
||||
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://<host>: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://<crowdsec-host>: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
|
||||
|
||||
@@ -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=
|
||||
@@ -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"]
|
||||
+598
@@ -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)
|
||||
@@ -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
|
||||
@@ -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://<LXC-IP>:3500
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- netmap
|
||||
|
||||
networks:
|
||||
netmap:
|
||||
driver: bridge
|
||||
@@ -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
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>NetMap — Network Traffic Globe</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Space+Grotesk:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body style="margin:0;background:#000;">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="floating-panel panel"
|
||||
style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header — drag handle */}
|
||||
<div className="fp-header" onMouseDown={onDragMouseDown}>
|
||||
<span className="fp-title">{title}</span>
|
||||
{badge != null && <span className="tab-count">{badge}</span>}
|
||||
{extra}
|
||||
<button className="close-btn" onMouseDown={e=>e.stopPropagation()} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="fp-body">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Resize handle — bottom-right corner */}
|
||||
<div className="fp-resize" onMouseDown={onResizeMouseDown}>⌟</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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(<App />);
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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?$/,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user