Files
mNetVisualizer/frontend/src/App.jsx
T

1196 lines
60 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}