1196 lines
60 KiB
React
1196 lines
60 KiB
React
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<SPARK_WINDOW).forEach(f => {
|
||
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<HISTORY_WINDOW),
|
||
...geoFlows.filter(f=>!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)<window2min).map(f => {
|
||
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 (
|
||
<div className="app" onClick={dismissAll}>
|
||
|
||
{/* Background */}
|
||
<div style={{position:'absolute',inset:0,background:theme.bg,zIndex:0}}/>
|
||
|
||
{/* ── SVG MAP ── */}
|
||
<svg ref={svgRef} className="world-svg" viewBox={viewBox} preserveAspectRatio="xMidYMid meet"
|
||
onMouseDown={handleMouseDown} onMouseMove={handleMouseMove}
|
||
onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}
|
||
style={{cursor:dragRef.current?'grabbing':'grab',display:'block',width:'100%',height:'100%',position:'absolute',inset:0,zIndex:1}}>
|
||
|
||
<defs>
|
||
{glowEnabled && <>
|
||
<filter id="glow-arc" x="-30%" y="-30%" width="160%" height="160%">
|
||
<feGaussianBlur stdDeviation="2.5" result="b"/>
|
||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||
</filter>
|
||
<filter id="glow-dot" x="-100%" y="-100%" width="300%" height="300%">
|
||
<feGaussianBlur stdDeviation="3" result="b"/>
|
||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||
</filter>
|
||
<filter id="glow-home" x="-100%" y="-100%" width="300%" height="300%">
|
||
<feGaussianBlur stdDeviation="4" result="b"/>
|
||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||
</filter>
|
||
</>}
|
||
</defs>
|
||
|
||
{/* Grid */}
|
||
<g opacity="0.04" stroke={theme.grid}>
|
||
{[-60,-30,0,30,60].map(lat => { const pts=Array.from({length:361},(_,i)=>projection([i-180,lat])).filter(Boolean); return <polyline key={lat} points={pts.map(p=>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 <polyline key={lon} points={pts.map(p=>p.join(',')).join(' ')} fill="none" strokeWidth="0.5" style={{vectorEffect:'non-scaling-stroke'}}/>; })}
|
||
</g>
|
||
|
||
{/* 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 (
|
||
<path key={f.id} d={pathGen(f)} fill={fill} stroke="none"
|
||
style={{cursor:'pointer', transition:'fill 0.15s'}}
|
||
onMouseEnter={() => 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 && <path d={pathGen(worldData.land)} fill="none" stroke={theme.coast} strokeWidth="0.8" style={{vectorEffect:'non-scaling-stroke'}}/>}
|
||
{worldData && <path d={pathGen(worldData.borders)} fill="none" stroke={theme.borders} strokeWidth="0.4" style={{vectorEffect:'non-scaling-stroke'}}/>}
|
||
|
||
{/* Night overlay */}
|
||
{showNight && nightPath && <path d={nightPath} fill="rgba(0,0,20,0.42)" stroke="rgba(80,130,220,0.2)" strokeWidth="0.8" style={{vectorEffect:'non-scaling-stroke',pointerEvents:'none'}}/>}
|
||
|
||
{/* 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 (
|
||
<g key={ban.ip} style={{cursor:'pointer'}} onClick={e=>{e.stopPropagation();setSelectedBan(ban);}}>
|
||
{ban.isNew && <circle cx={bx} cy={by} r={r*2} fill="none" stroke="#cc2200" strokeWidth={0.8*invScale} opacity="0.6"><animate attributeName="r" values={`${r};${r*4};${r}`} dur="2s" repeatCount="3"/><animate attributeName="opacity" values="0.7;0;0.7" dur="2s" repeatCount="3"/></circle>}
|
||
<line x1={bx-r} y1={by-r} x2={bx+r} y2={by+r} stroke="#cc2200" strokeWidth={1.5*invScale} strokeLinecap="round" style={{vectorEffect:'non-scaling-stroke'}}/>
|
||
<line x1={bx+r} y1={by-r} x2={bx-r} y2={by+r} stroke="#cc2200" strokeWidth={1.5*invScale} strokeLinecap="round" style={{vectorEffect:'non-scaling-stroke'}}/>
|
||
<circle cx={bx} cy={by} r={r*2} fill="transparent"/>
|
||
</g>
|
||
);
|
||
})}
|
||
|
||
{/* 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 (
|
||
<g key={a.id} filter={glowEnabled?'url(#glow-arc)':undefined}
|
||
onClick={e=>handleArcClick(a,e)} style={{cursor:'pointer'}}>
|
||
{glowEnabled && <path d={a.path} fill="none" stroke={a.color} strokeWidth={coreW*2.5} opacity={opacity*0.18} strokeLinecap="round"/>}
|
||
<path d={a.path} fill="none" stroke={a.color} strokeWidth={coreW} opacity={opacity} strokeLinecap="round"/>
|
||
{/* Animated direction dot */}
|
||
{showArcAnim && opacity > 0.2 && (
|
||
<circle r={Math.max(1.5, coreW*1.8)} fill={a.color} opacity={opacity*0.9}>
|
||
<animateMotion dur={dotDur} repeatCount="indefinite" path={a.path}/>
|
||
</circle>
|
||
)}
|
||
<path d={a.path} fill="none" stroke="transparent" strokeWidth={Math.max(10,coreW*3)*sw}/>
|
||
</g>
|
||
);
|
||
})}
|
||
|
||
{/* Remote dots */}
|
||
{remoteDots.map((a,i) => (
|
||
<g key={i} filter={glowEnabled?'url(#glow-dot)':undefined}>
|
||
<circle cx={a.rx} cy={a.ry} r={4*invScale} fill="none" stroke={a.color} strokeWidth={0.8*invScale} opacity="0.3"/>
|
||
<circle cx={a.rx} cy={a.ry} r={2*invScale} fill={a.color} opacity="0.9"/>
|
||
{a.remote_geo?.city && <text x={a.rx+5*invScale} y={a.ry+3*invScale} fontSize={8*invScale} fill="#7ea8c4" opacity="0.85" fontFamily="JetBrains Mono,monospace" style={{pointerEvents:'none'}}>{a.remote_geo.city}</text>}
|
||
</g>
|
||
))}
|
||
|
||
{/* Home */}
|
||
{homeXY && (
|
||
<g filter={glowEnabled?'url(#glow-home)':undefined}>
|
||
<circle cx={homeXY[0]} cy={homeXY[1]} r={10*invScale} fill="none" stroke="#fff" strokeWidth={0.6*invScale} opacity="0.12">
|
||
<animate attributeName="r" values={`${8*invScale};${15*invScale};${8*invScale}`} dur="3s" repeatCount="indefinite"/>
|
||
<animate attributeName="opacity" values="0.2;0.03;0.2" dur="3s" repeatCount="indefinite"/>
|
||
</circle>
|
||
<circle cx={homeXY[0]} cy={homeXY[1]} r={3.5*invScale} fill="none" stroke="#fff" strokeWidth={1.2*invScale} opacity="0.85"/>
|
||
<circle cx={homeXY[0]} cy={homeXY[1]} r={1.5*invScale} fill="#fff"/>
|
||
<text x={homeXY[0]+7*invScale} y={homeXY[1]-5*invScale} fontSize={9*invScale} fill="#fff" opacity="0.75" fontFamily="JetBrains Mono,monospace" style={{pointerEvents:'none'}}>{home.name}</text>
|
||
</g>
|
||
)}
|
||
</svg>
|
||
|
||
{/* Zoom controls */}
|
||
<div className="zoom-controls">
|
||
<button onClick={zoomIn} title="Zoom in">+</button>
|
||
<button onClick={zoomReset} title="Reset" className="zoom-reset">⌂</button>
|
||
<button onClick={zoomOut} title="Zoom out">−</button>
|
||
</div>
|
||
|
||
{/* ── CLOCK ── */}
|
||
<div className="clock panel">
|
||
<span className="clock-time mono">{clockTime}</span>
|
||
<span className="clock-date">{clockDate}</span>
|
||
</div>
|
||
|
||
{/* ── TOPBAR ── */}
|
||
<header className="topbar panel">
|
||
<div className="topbar-left">
|
||
<div className="logo"><span className="logo-icon">◈</span><span className="logo-text">NetMap</span></div>
|
||
<div className={`conn-badge ${connected?'conn-ok':'conn-err'}`}>
|
||
<span className="conn-dot"/>{connected?'Live':'Reconnecting…'}
|
||
</div>
|
||
</div>
|
||
<div className="topbar-stats">
|
||
<Stat label="↑ Out (30s)" value={formatBytes(stats.out)} color={OUT_COLOR}/>
|
||
<Stat label="↓ In (30s)" value={formatBytes(stats.inc)} color={IN_COLOR}/>
|
||
<Stat label="Active" value={activeDevices.length} color="#a78bfa"/>
|
||
<Stat label="Arcs" value={arcs.length} color="#4ade80"/>
|
||
{crowdsecEnabled && <Stat label={`⊘ Banned (${crowdsecOrigin})`} value={crowdsecBans.length} color={BLOCKED_COLOR}/>}
|
||
</div>
|
||
<div className="topbar-toggles">
|
||
<Toggle label="Outbound" active={showOutbound} color={OUT_COLOR} onClick={()=>setShowOutbound(v=>!v)}/>
|
||
<Toggle label="Inbound" active={showInbound} color={IN_COLOR} onClick={()=>setShowInbound(v=>!v)}/>
|
||
<Toggle label="🌙 Night" active={showNight} color="#6488c0" onClick={()=>setShowNight(v=>!v)}/>
|
||
{crowdsecEnabled && showCrowdsec && (
|
||
<div className="origin-toggle">
|
||
<button className={crowdsecOrigin==='local'?'active':''} onClick={()=>setCrowdsecOrigin('local')} title="Hits on your services">Me {crowdsecCounts.local>0&&<span className="origin-count">{crowdsecCounts.local}</span>}</button>
|
||
<button className={crowdsecOrigin==='capi' ?'active':''} onClick={()=>setCrowdsecOrigin('capi')} title="Community list">CAPI {crowdsecCounts.capi>0&&<span className="origin-count">{crowdsecCounts.capi}</span>}</button>
|
||
<button className={crowdsecOrigin==='all' ?'active':''} onClick={()=>setCrowdsecOrigin('all')} title="All">All {crowdsecCounts.total>0&&<span className="origin-count">{crowdsecCounts.total}</span>}</button>
|
||
</div>
|
||
)}
|
||
{crowdsecEnabled && <Toggle label="⊘ Bans" active={showCrowdsec} color={BLOCKED_COLOR} onClick={()=>setShowCrowdsec(v=>!v)}/>}
|
||
<Toggle label="✦ Glow" active={glowEnabled} color="#a78bfa" onClick={()=>setGlowEnabled(v=>!v)}/>
|
||
<Toggle label="⟶ Anim" active={showArcAnim} color="#34d399" onClick={()=>setShowArcAnim(v=>!v)}/>
|
||
<Toggle label="🎨 Map" active={showPicker} color="#fbbf24" onClick={()=>setShowPicker(v=>!v)}/>
|
||
</div>
|
||
</header>
|
||
|
||
{/* ── LEFT DOCK ── */}
|
||
<div className="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 => (
|
||
<button key={p.id}
|
||
className={`dock-btn ${openPanels.includes(p.id)?'dock-active':''}`}
|
||
onClick={e=>{e.stopPropagation();togglePanel(p.id);}}
|
||
title={p.label}>
|
||
<span className="dock-icon">{p.icon}</span>
|
||
<span className="dock-label">{p.label}</span>
|
||
{p.id==='devices' && <span className="dock-count">{activeDevices.length}</span>}
|
||
</button>
|
||
))}
|
||
<div className="dock-divider"/>
|
||
<button
|
||
className={`dock-btn ${showHeatmap?'dock-active':''}`}
|
||
onClick={e=>{e.stopPropagation();setShowHeatmap(v=>!v);}}
|
||
title="Connection Heatmap">
|
||
<span className="dock-icon">🌡</span>
|
||
<span className="dock-label">Heatmap</span>
|
||
</button>
|
||
<div className="dock-divider"/>
|
||
<button
|
||
className={`dock-btn ${replayMode?'dock-active':''}`}
|
||
onClick={e=>{e.stopPropagation();setReplayMode(v=>!v);setReplayPlaying(false);}}
|
||
title="Arc History Replay">
|
||
<span className="dock-icon">⏮</span>
|
||
<span className="dock-label">Replay</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── FLOATING PANELS ── */}
|
||
|
||
{/* Devices panel */}
|
||
{openPanels.includes('devices') && (
|
||
<DraggablePanel id="devices" title="Devices" badge={activeDevices.length}
|
||
onClose={()=>togglePanel('devices')} defaultPos={{x:70,y:74}}>
|
||
<div className="search-wrap">
|
||
<span className="search-icon">⌕</span>
|
||
<input className="search-input" placeholder="Search…" value={search}
|
||
onChange={e=>setSearch(e.target.value)} spellCheck={false}/>
|
||
{search && <button className="search-clear" onClick={()=>setSearch('')}>✕</button>}
|
||
</div>
|
||
<div className="device-list">
|
||
<button className={`device-item all-devices ${!selectedDevice?'selected':''}`} onClick={()=>setSelectedDevice(null)}>
|
||
<span className="device-icon">🌐</span>
|
||
<div className="device-info"><span className="device-name">All Devices</span><span className="device-sub mono">{activeDevices.length} active</span></div>
|
||
</button>
|
||
{filteredDevices.length===0&&search&&<div className="empty-state">No match for "{search}"</div>}
|
||
{filteredDevices.map(d=>(
|
||
<button key={d.mac} className={`device-item ${selectedDevice===d.mac?'selected':''}`}
|
||
onClick={()=>setSelectedDevice(p=>p===d.mac?null:d.mac)}>
|
||
<span className="device-icon">{deviceIcon(d)}</span>
|
||
<div className="device-info">
|
||
<span className="device-name">
|
||
{deviceGroups[d.mac] && <span className="group-dot" style={{background:GROUP_COLORS[deviceGroups[d.mac]]}}/>}
|
||
{d.name||d.mac}
|
||
</span>
|
||
<span className="device-sub mono">{d.ip}</span>
|
||
{sparklines[d.mac] && <Sparkline data={sparklines[d.mac]}/>}
|
||
</div>
|
||
<div className="device-traffic">
|
||
<span style={{color:OUT_COLOR}}>↑ {formatBytes(d.tx_bytes)}</span>
|
||
<span style={{color:IN_COLOR}}>↓ {formatBytes(d.rx_bytes)}</span>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</DraggablePanel>
|
||
)}
|
||
|
||
{/* Flow log panel */}
|
||
{openPanels.includes('flowlog') && (
|
||
<DraggablePanel id="flowlog" title="Flow Log"
|
||
extra={<button className="export-btn-sm" onClick={exportCSV}>⬇ CSV</button>}
|
||
onClose={()=>togglePanel('flowlog')} defaultPos={{x:354,y:74}}>
|
||
<div className="flow-log">
|
||
{visibleFlows.length===0&&<div className="empty-state">No flows yet</div>}
|
||
{visibleFlows.map(f=>(
|
||
<div key={f.id} className={`flow-entry flow-${f.type} ${f.blocked?'flow-blocked':''}`}
|
||
onClick={()=>{ setSelectedArc(f); setAsnInfo(''); if(f.remote_ip) lookupASN(f.remote_ip); }} style={{cursor:'pointer'}}>
|
||
<span className={`flow-dir ${f.type==='outbound'?'out':'in'} ${f.blocked?'blocked':''}`}>
|
||
{f.blocked?'⊘':f.type==='outbound'?'↑':'↓'}
|
||
</span>
|
||
<div className="flow-detail">
|
||
<span className="flow-device">{f.device_name}</span>
|
||
{f.remote_geo&&<span className="flow-dest mono">{f.remote_geo.city?`${f.remote_geo.city}, `:''}{f.remote_geo.country}</span>}
|
||
{f.remote_ip&&<span className="flow-ip mono">{f.remote_ip}</span>}
|
||
</div>
|
||
<span className="flow-bytes mono">{formatBytes(f.bytes)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</DraggablePanel>
|
||
)}
|
||
|
||
{/* Top talkers panel */}
|
||
{openPanels.includes('talkers') && (
|
||
<DraggablePanel id="talkers" title="Top Talkers"
|
||
extra={<span className="talkers-sub">60s</span>}
|
||
onClose={()=>togglePanel('talkers')} defaultPos={{x:638,y:74}}>
|
||
<div className="talker-list">
|
||
{topTalkers.length===0&&<div className="empty-state">No traffic yet</div>}
|
||
{topTalkers.map((t,i)=>{
|
||
const pct=Math.round((t.total/topTalkers[0].total)*100);
|
||
return (
|
||
<div key={i} className="talker-row">
|
||
<span className="talker-rank">{i+1}</span>
|
||
<div className="talker-info">
|
||
<span className="talker-name">{t.name}</span>
|
||
<div className="talker-bar-wrap"><div className="talker-bar" style={{width:`${pct}%`}}/></div>
|
||
</div>
|
||
<div className="talker-bytes">
|
||
<span style={{color:OUT_COLOR}}>↑{formatBytes(t.out)}</span>
|
||
<span style={{color:IN_COLOR}}>↓{formatBytes(t.inc)}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</DraggablePanel>
|
||
)}
|
||
|
||
{/* Protocol breakdown panel */}
|
||
{openPanels.includes('proto') && (
|
||
<DraggablePanel id="proto" title="Protocols"
|
||
extra={<span className="talkers-sub">5 min</span>}
|
||
onClose={()=>togglePanel('proto')} defaultPos={{x:70,y:300}}>
|
||
<div className="proto-list">
|
||
{protoStats.length===0&&<div className="empty-state">No protocol data yet</div>}
|
||
{protoStats.map(p=>(
|
||
<div key={p.proto} className="proto-row">
|
||
<span className="proto-name" style={{color:protoColor(p.proto)}}>{protoName(p.proto)}</span>
|
||
<div className="proto-bar-wrap">
|
||
<div className="proto-bar" style={{width:`${p.pct}%`,background:protoColor(p.proto)}}/>
|
||
</div>
|
||
<div className="proto-stats">
|
||
<span className="proto-pct">{p.pct.toFixed(1)}%</span>
|
||
<span className="proto-bytes mono">{formatBytes(p.bytes)}</span>
|
||
<span className="proto-count mono">{p.count} flows</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</DraggablePanel>
|
||
)}
|
||
|
||
{/* Groups panel */}
|
||
{openPanels.includes('groups') && (
|
||
<DraggablePanel id="groups" title="Device Groups"
|
||
onClose={()=>togglePanel('groups')} defaultPos={{x:70,y:400}}>
|
||
<div className="groups-body">
|
||
{/* Group filter buttons */}
|
||
<div className="group-filters">
|
||
<button className={`group-filter-btn ${!selectedGroup?'active':''}`}
|
||
onClick={()=>setSelectedGroup(null)}>All</button>
|
||
{DEVICE_GROUPS.map(g => (
|
||
<button key={g} className={`group-filter-btn ${selectedGroup===g?'active':''}`}
|
||
style={selectedGroup===g?{borderColor:GROUP_COLORS[g],color:GROUP_COLORS[g]}:{}}
|
||
onClick={()=>setSelectedGroup(prev=>prev===g?null:g)}>
|
||
{g} <span className="group-count">{activeDevices.filter(d=>deviceGroups[d.mac]===g).length}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="group-device-list">
|
||
{activeDevices.map(d => {
|
||
const grp = deviceGroups[d.mac] || '';
|
||
return (
|
||
<div key={d.mac} className="group-device-row">
|
||
<span className="device-icon">{deviceIcon(d)}</span>
|
||
<div className="group-device-info">
|
||
<span className="device-name">{d.name||d.mac}</span>
|
||
<span className="device-sub mono">{d.ip}</span>
|
||
</div>
|
||
<select className="group-select"
|
||
value={grp}
|
||
onChange={e => setDeviceGroups(prev=>({...prev,[d.mac]:e.target.value}))}
|
||
style={grp?{color:GROUP_COLORS[grp]}:{}}>
|
||
<option value="">—</option>
|
||
{DEVICE_GROUPS.map(g=><option key={g} value={g}>{g}</option>)}
|
||
</select>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</DraggablePanel>
|
||
)}
|
||
|
||
{/* Traffic timeline */}
|
||
<div className="timeline panel" onClick={e=>e.stopPropagation()}>
|
||
<div className="timeline-header">
|
||
<span className="timeline-label">Traffic — last 1h</span>
|
||
<div className="timeline-stats">
|
||
{showTimelineOut && (
|
||
<span className="timeline-stat-out" title="Total outbound this hour">
|
||
↑ {formatBytes(timeline.totalOut)}
|
||
</span>
|
||
)}
|
||
{showTimelineIn && (
|
||
<span className="timeline-stat-in" title="Total inbound this hour">
|
||
↓ {formatBytes(timeline.totalInc)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="timeline-toggles">
|
||
<button className={`timeline-toggle ${showTimelineOut?'on-out':''}`}
|
||
onClick={()=>setShowTimelineOut(v=>!v)}>↑ Out</button>
|
||
<button className={`timeline-toggle ${showTimelineIn?'on-in':''}`}
|
||
onClick={()=>setShowTimelineIn(v=>!v)}>↓ In</button>
|
||
</div>
|
||
</div>
|
||
<div className="timeline-wrap"
|
||
onMouseLeave={()=>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)));
|
||
}}>
|
||
<svg className="timeline-svg" height="36" preserveAspectRatio="none"
|
||
viewBox={`0 0 ${TIMELINE_BUCKETS} 36`}>
|
||
{showTimelineOut && timeline.out.map((v,i) => {
|
||
const h = (v/timeline.maxVal)*34;
|
||
return <rect key={i} x={i} y={36-h} width="0.9" height={h}
|
||
fill={OUT_COLOR} opacity={timelineHover===i?1:0.65}/>;
|
||
})}
|
||
{showTimelineIn && timeline.inc.map((v,i) => {
|
||
const h = (v/timeline.maxVal)*34;
|
||
return <rect key={i} x={i+0.05} y={36-h} width="0.85" height={h}
|
||
fill={IN_COLOR} opacity={timelineHover===i?1:0.5}/>;
|
||
})}
|
||
</svg>
|
||
{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 (
|
||
<>
|
||
<div className="timeline-cursor" style={{left:`${leftPct}%`}}/>
|
||
<div className="timeline-tooltip" style={{left:`${clampedLeft}%`, transform:`translateX(${transformX})`}}>
|
||
{minsAgo}m ago
|
||
{showTimelineOut && ` · ↑${formatBytes(timeline.out[timelineHover])}`}
|
||
{showTimelineIn && ` · ↓${formatBytes(timeline.inc[timelineHover])}`}
|
||
</div>
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Country filter badge */}
|
||
{selectedCountry && (
|
||
<div className="country-badge panel" onClick={e=>e.stopPropagation()}>
|
||
<span>Filtered: <strong>{selectedCountry}</strong></span>
|
||
<span className="country-badge-count">{countryDeviceMacs?.size||0} device{countryDeviceMacs?.size!==1?'s':''}</span>
|
||
<button className="close-btn" onClick={()=>setSelectedCountry(null)}>✕</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Replay controls overlay */}
|
||
{replayMode && (
|
||
<div className="replay-bar panel" onClick={e=>e.stopPropagation()}>
|
||
<div className="replay-header">
|
||
<span className="replay-label">⏮ Replay</span>
|
||
{replayTimestamp && <span className="replay-time mono">{replayTimestamp.toLocaleTimeString('no-NO')}</span>}
|
||
<span className="replay-count">{replayArcs?.length||0} arcs</span>
|
||
<button className="replay-live" onClick={()=>{setReplayMode(false);setReplayPlaying(false);}}>Back to Live</button>
|
||
</div>
|
||
<div className="replay-controls">
|
||
<button className="replay-btn" onClick={()=>{setReplayPos(0);setReplayPlaying(true);}}>⏮</button>
|
||
<button className="replay-btn" onClick={()=>setReplayPlaying(v=>!v)}>
|
||
{replayPlaying?'⏸':'▶'}
|
||
</button>
|
||
<button className="replay-btn" onClick={()=>{setReplayPos(100);setReplayPlaying(false);}}>⏭</button>
|
||
<input type="range" className="replay-slider" min="0" max="100" step="0.1"
|
||
value={replayPos} onChange={e=>{setReplayPos(Number(e.target.value));setReplayPlaying(false);}}/>
|
||
<span className="replay-pos mono">{replayPos.toFixed(0)}%</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Heatmap legend */}
|
||
{showHeatmap && (
|
||
<div className="heatmap-legend panel">
|
||
<div className="heatmap-title">Connections (5 min)</div>
|
||
<div className="heatmap-scale">
|
||
<span className="heatmap-min">0</span>
|
||
<div className="heatmap-gradient"/>
|
||
<span className="heatmap-max">{maxCountryCount}</span>
|
||
</div>
|
||
{maxCountry && (
|
||
<div className="heatmap-top">Top: <strong>{maxCountry.code}</strong> — {maxCountry.count} flows</div>
|
||
)}
|
||
{hoveredCountry && (() => {
|
||
const alpha2 = NUM_TO_ALPHA2[parseInt(hoveredCountry)];
|
||
const count = alpha2 ? (countryCounts[alpha2] || 0) : 0;
|
||
return <div className="heatmap-hover">{alpha2||'?'} — {count} flow{count!==1?'s':''}</div>;
|
||
})()}
|
||
</div>
|
||
)}
|
||
|
||
{selectedArc && (
|
||
<div className="arc-popup panel" onClick={e=>e.stopPropagation()}>
|
||
<div className="arc-popup-header">
|
||
<span className="arc-popup-dir" style={{color:selectedArc.blocked?BLOCKED_COLOR:selectedArc.type==='outbound'?OUT_COLOR:IN_COLOR}}>
|
||
{selectedArc.blocked?'⊘ Blocked':selectedArc.type==='outbound'?'↑ Outbound':'↓ Inbound'}
|
||
</span>
|
||
<button className="close-btn" onClick={dismissAll}>✕</button>
|
||
</div>
|
||
<div className="arc-popup-body">
|
||
<ArcRow label="Device" value={selectedArc.device_name||'—'}/>
|
||
<ArcRow label="Local IP" value={selectedArc.device_ip||'—'} mono/>
|
||
<ArcRow label="Remote" value={selectedArc.remote_geo?`${selectedArc.remote_geo.city?selectedArc.remote_geo.city+', ':''}${selectedArc.remote_geo.country}`:'—'}/>
|
||
<ArcRow label="Remote IP" value={selectedArc.remote_ip||'—'} mono/>
|
||
{rdnsInfo && <ArcRow label="Hostname" value={rdnsInfo} mono/>}
|
||
<ArcRow label="ASN/Org" value={asnInfo||'—'}/>
|
||
{selectedArc.proto>0 && <ArcRow label="Protocol" value={protoName(selectedArc.proto)}/>}
|
||
{selectedArc.dst_port>0 && <ArcRow label="Service" value={serviceLabel(selectedArc.dst_port, selectedArc.proto)||`port ${selectedArc.dst_port}`} mono/>}
|
||
<ArcRow label="Transfer" value={formatBytes(selectedArc.bytes)}/>
|
||
{selectedArc.packets>0&&<ArcRow label="Packets" value={selectedArc.packets?.toLocaleString()}/>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── BAN POPUP ── */}
|
||
{selectedBan && (
|
||
<div className="arc-popup panel" onClick={e=>e.stopPropagation()}>
|
||
<div className="arc-popup-header">
|
||
<span className="arc-popup-dir" style={{color:BLOCKED_COLOR}}>⊘ CrowdSec Ban</span>
|
||
<button className="close-btn" onClick={()=>setSelectedBan(null)}>✕</button>
|
||
</div>
|
||
<div className="arc-popup-body">
|
||
<ArcRow label="IP" value={selectedBan.ip} mono/>
|
||
<ArcRow label="Country" value={selectedBan.geo?.country||'—'}/>
|
||
<ArcRow label="City" value={selectedBan.geo?.city||'—'}/>
|
||
<ArcRow label="Scenario" value={selectedBan.scenario||'—'}/>
|
||
<ArcRow label="Origin" value={selectedBan.origin||'—'}/>
|
||
<ArcRow label="Expires" value={selectedBan.expires_in||'—'}/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── DEVICE DETAIL ── */}
|
||
{(() => {
|
||
const d = selectedDevice ? devices.find(x=>x.mac===selectedDevice) : null;
|
||
if (!d) return null;
|
||
return (
|
||
<div className="device-detail panel">
|
||
<div className="detail-header">
|
||
<span className="detail-icon">{deviceIcon(d)}</span>
|
||
<div><div className="detail-name">{d.name}</div><div className="detail-mac mono">{d.mac}</div></div>
|
||
<button className="close-btn" onClick={()=>setSelectedDevice(null)}>✕</button>
|
||
</div>
|
||
<div className="detail-grid">
|
||
<DetailRow label="IP" value={d.ip} mono/>
|
||
<DetailRow label="Type" value={d.is_wired?'Wired':'Wireless'}/>
|
||
<DetailRow label="↑ Sent" value={formatBytes(d.tx_bytes)} color={OUT_COLOR}/>
|
||
<DetailRow label="↓ Received" value={formatBytes(d.rx_bytes)} color={IN_COLOR}/>
|
||
{d.signal&&<DetailRow label="Signal" value={`${d.signal} dBm`}/>}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* ── COLOR PICKER ── */}
|
||
{showPicker && (
|
||
<div className="color-picker panel" onClick={e=>e.stopPropagation()}>
|
||
<div className="picker-header">
|
||
<span className="picker-title">Map Colors</span>
|
||
<button className="picker-reset" onClick={resetTheme}>Reset</button>
|
||
<button className="close-btn" onClick={()=>setShowPicker(false)}>✕</button>
|
||
</div>
|
||
<div className="picker-rows">
|
||
<ColorRow label="Background" value={theme.bg} onChange={v=>updateTheme('bg',v)}/>
|
||
<ColorRow label="Land fill" value={theme.land} onChange={v=>updateTheme('land',v)}/>
|
||
<ColorRow label="Coastlines" value={theme.coast} onChange={v=>updateTheme('coast',v)}/>
|
||
<ColorRow label="Borders" value={theme.borders} onChange={v=>updateTheme('borders',v)}/>
|
||
<ColorRow label="Grid" value={theme.grid} onChange={v=>updateTheme('grid',v)}/>
|
||
</div>
|
||
<div className="picker-presets">
|
||
<span className="picker-preset-label">Presets</span>
|
||
<div className="preset-btns">
|
||
{PRESETS.map(p=>(
|
||
<button key={p.name} className="preset-btn" onClick={()=>setTheme(p.theme)} title={p.name}>
|
||
<span style={{background:p.theme.coast}} className="preset-swatch"/>{p.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!geoipLoaded&&connected&&<div className="geoip-warning panel">⚠ GeoIP database not loaded</div>}
|
||
<style>{`@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}} @keyframes fadeIn{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}`}</style>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Stat({label,value,color}) {
|
||
return <div className="stat"><span className="stat-label">{label}</span><span className="stat-value mono" style={{color}}>{value}</span></div>;
|
||
}
|
||
function Toggle({label,active,color,onClick}) {
|
||
return <button className={`toggle-btn ${active?'toggle-active':''}`} style={active?{borderColor:color,color}:{}} onClick={onClick}>{label}</button>;
|
||
}
|
||
function DetailRow({label,value,mono,color}) {
|
||
return <div className="detail-row"><span className="detail-label">{label}</span><span className={`detail-value${mono?' mono':''}`} style={color?{color}:{}}>{value}</span></div>;
|
||
}
|
||
function ArcRow({label,value,mono}) {
|
||
return <div className="arc-row"><span className="arc-label">{label}</span><span className={`arc-value${mono?' mono':''}`}>{value}</span></div>;
|
||
}
|
||
function ColorRow({label,value,onChange}) {
|
||
return (
|
||
<div className="color-row">
|
||
<span className="color-label">{label}</span>
|
||
<div className="color-input-wrap">
|
||
<input type="color" value={value} onChange={e=>onChange(e.target.value)} className="color-native"/>
|
||
<span className="color-hex mono">{value}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Sparkline({ data }) {
|
||
const max = Math.max(...data, 1);
|
||
const W = 60, H = 14;
|
||
const bw = W / data.length - 1;
|
||
return (
|
||
<svg width={W} height={H} style={{display:'block',marginTop:2}}>
|
||
{data.map((v,i) => {
|
||
const h = Math.max(1, (v/max)*H);
|
||
return <rect key={i} x={i*(bw+1)} y={H-h} width={bw} height={h}
|
||
fill={v>0?'#38bdf855':'#ffffff10'} rx="1"/>;
|
||
})}
|
||
</svg>
|
||
);
|
||
}
|