feat: initial release - MorkNetVizualiser v1.0

This commit is contained in:
2026-06-07 19:31:43 +02:00
commit 5f8a43e652
19 changed files with 2954 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY index.html .
COPY vite.config.js .
COPY public/ public/
COPY src/ src/
ARG REACT_APP_WS_URL
ENV VITE_WS_URL=$REACT_APP_WS_URL
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NetMap — Network Traffic Globe</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Space+Grotesk:wght@300;400;500;600&display=swap" rel="stylesheet" />
</head>
<body style="margin:0;background:#000;">
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Proxy WebSocket to backend
location /ws {
proxy_pass http://backend:8000/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
# Proxy API to backend
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "netmap-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"d3-geo": "^3.1.1",
"topojson-client": "^3.1.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.0"
},
"scripts": {
"start": "vite",
"build": "vite build",
"preview": "vite preview"
}
}
+1195
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
const MIN_W = 220;
const MIN_H = 160;
export function DraggablePanel({ id, title, badge, extra, onClose, children, defaultPos }) {
const [pos, setPos] = useState(() => {
try { const s = localStorage.getItem(`panel-pos-${id}`); return s ? JSON.parse(s) : defaultPos; }
catch { return defaultPos; }
});
const [size, setSize] = useState(() => {
try { const s = localStorage.getItem(`panel-size-${id}`); return s ? JSON.parse(s) : { w: 272, h: 480 }; }
catch { return { w: 272, h: 480 }; }
});
const savePos = (p) => { try { localStorage.setItem(`panel-pos-${id}`, JSON.stringify(p)); } catch {} };
const saveSize = (s) => { try { localStorage.setItem(`panel-size-${id}`, JSON.stringify(s)); } catch {} };
const dragRef = useRef(null);
const resizeRef = useRef(null);
const panelRef = useRef(null);
// ── Drag ──────────────────────────────────────────────────────────────────
const onDragMouseDown = useCallback((e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
}, [pos]);
// ── Resize ────────────────────────────────────────────────────────────────
const onResizeMouseDown = useCallback((e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: size.w, origH: size.h };
}, [size]);
useEffect(() => {
const onMouseMove = (e) => {
if (dragRef.current) {
const d = dragRef.current;
const nx = Math.max(0, Math.min(window.innerWidth - size.w, d.origX + (e.clientX - d.startX)));
const ny = Math.max(0, Math.min(window.innerHeight - 60, d.origY + (e.clientY - d.startY)));
const p = { x: nx, y: ny };
setPos(p);
savePos(p);
}
if (resizeRef.current) {
const d = resizeRef.current;
const nw = Math.max(MIN_W, d.origW + (e.clientX - d.startX));
const nh = Math.max(MIN_H, d.origH + (e.clientY - d.startY));
const s = { w: nw, h: nh };
setSize(s);
saveSize(s);
}
};
const onMouseUp = () => { dragRef.current = null; resizeRef.current = null; };
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); };
}, [size.w]);
return (
<div
ref={panelRef}
className="floating-panel panel"
style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
onClick={e => e.stopPropagation()}
>
{/* Header — drag handle */}
<div className="fp-header" onMouseDown={onDragMouseDown}>
<span className="fp-title">{title}</span>
{badge != null && <span className="tab-count">{badge}</span>}
{extra}
<button className="close-btn" onMouseDown={e=>e.stopPropagation()} onClick={onClose}></button>
</div>
{/* Content */}
<div className="fp-body">
{children}
</div>
{/* Resize handle — bottom-right corner */}
<div className="fp-resize" onMouseDown={onResizeMouseDown}></div>
</div>
);
}
+400
View File
@@ -0,0 +1,400 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--border: rgba(56,189,248,0.11);
--border-h: rgba(56,189,248,0.26);
--accent-out: #38bdf8;
--accent-in: #f472b6;
--blocked: #cc2200;
--text: #dde6f0;
--text-dim: #445566;
--mono: 'JetBrains Mono', monospace;
--ui: 'Space Grotesk', sans-serif;
--green: #4ade80;
--yellow: #fbbf24;
--panel-bg: rgba(4,8,16,0.96);
}
body { color:var(--text); font-family:var(--ui); overflow:hidden; height:100vh; width:100vw; }
::-webkit-scrollbar { width:3px; } ::-webkit-scrollbar-track { background:transparent; } ::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
.panel { background:var(--panel-bg); border:1px solid var(--border); border-radius:12px; backdrop-filter:blur(20px); }
button { font-family:var(--ui); cursor:pointer; border:none; outline:none; }
.mono { font-family:var(--mono); }
/* ── Layout ── */
.app { position:relative; width:100vw; height:100vh; overflow:hidden; }
.world-svg { position:absolute; inset:0; width:100%; height:100%; display:block; }
/* ── Zoom ── */
.zoom-controls { position:absolute; bottom:72px; right:20px; display:flex; flex-direction:column; gap:4px; z-index:20; }
.zoom-controls button { width:32px; height:32px; background:var(--panel-bg); border:1px solid var(--border); color:var(--text-dim); border-radius:8px; font-size:17px; display:flex; align-items:center; justify-content:center; transition:all 0.15s; }
.zoom-controls button:hover { border-color:var(--border-h); color:var(--accent-out); }
.zoom-reset { font-size:12px !important; }
/* ── Clock ── */
.clock { position:absolute; top:14px; left:14px; padding:8px 14px; z-index:20; display:flex; flex-direction:column; gap:2px; }
.clock-time { font-size:18px; font-weight:600; letter-spacing:0.05em; }
.clock-date { font-size:10px; color:var(--text-dim); }
/* ── Topbar ── */
.topbar { position:absolute; top:14px; left:50%; transform:translateX(-50%); display:flex; align-items:center; gap:20px; padding:9px 18px; white-space:nowrap; z-index:20; }
.topbar-left { display:flex; align-items:center; gap:12px; }
.logo { display:flex; align-items:center; gap:7px; }
.logo-icon { font-size:16px; color:var(--accent-out); filter:drop-shadow(0 0 5px var(--accent-out)); }
.logo-text { font-size:14px; font-weight:600; letter-spacing:0.08em; }
.conn-badge { display:flex; align-items:center; gap:5px; font-size:10px; font-weight:500; padding:2px 8px; border-radius:20px; }
.conn-ok { background:rgba(74,222,128,0.08); color:var(--green); border:1px solid rgba(74,222,128,0.2); }
.conn-err { background:rgba(248,113,113,0.08); color:#f87171; border:1px solid rgba(248,113,113,0.2); }
.conn-dot { width:5px; height:5px; border-radius:50%; background:currentColor; animation:pulse 2s infinite; }
.topbar-stats { display:flex; gap:16px; }
.stat { display:flex; flex-direction:column; align-items:center; gap:1px; }
.stat-label { font-size:8px; font-weight:500; letter-spacing:0.1em; color:var(--text-dim); text-transform:uppercase; }
.stat-value { font-size:12px; font-weight:600; }
.topbar-toggles { display:flex; gap:6px; align-items:center; }
.toggle-btn { padding:4px 11px; border-radius:20px; font-size:11px; font-weight:500; background:rgba(255,255,255,0.04); border:1px solid var(--border); color:var(--text-dim); transition:all 0.2s; }
.toggle-btn:hover { color:var(--text); }
.toggle-active { background:rgba(255,255,255,0.07) !important; }
.origin-toggle { display:flex; border-radius:20px; overflow:hidden; border:1px solid var(--border); }
.origin-toggle button { padding:4px 9px; font-size:10px; font-weight:500; background:rgba(255,255,255,0.04); color:var(--text-dim); border:none; border-left:1px solid var(--border); transition:all 0.15s; }
.origin-toggle button:first-child { border-left:none; }
.origin-toggle button:hover { color:var(--text); }
.origin-toggle button.active { background:rgba(204,34,0,0.15); color:#ff6666; }
.origin-count { display:inline-block; font-size:8px; font-family:var(--mono); background:rgba(255,255,255,0.1); border-radius:8px; padding:0 4px; margin-left:3px; }
/* ── Left dock ── */
.left-dock {
position:absolute; left:0; top:50%; transform:translateY(-50%);
display:flex; flex-direction:column; gap:2px;
background:var(--panel-bg); border:1px solid var(--border);
border-left:none; border-radius:0 12px 12px 0;
padding:6px 4px; z-index:20;
}
.dock-btn {
display:flex; flex-direction:column; align-items:center; gap:3px;
padding:10px 10px; border-radius:8px; background:transparent;
color:var(--text-dim); transition:all 0.15s; min-width:54px;
border:1px solid transparent; position:relative;
}
.dock-btn:hover { background:rgba(255,255,255,0.05); color:var(--text); }
.dock-btn.dock-active { background:rgba(56,189,248,0.08); color:var(--accent-out); border-color:rgba(56,189,248,0.2); }
.dock-icon { font-size:16px; line-height:1; }
.dock-label { font-size:9px; font-weight:500; letter-spacing:0.04em; white-space:nowrap; }
.dock-count { position:absolute; top:5px; right:5px; font-size:8px; font-family:var(--mono); background:rgba(56,189,248,0.2); color:var(--accent-out); border-radius:8px; padding:0 4px; }
/* ── Panel stack ── */
.panel-stack {
position:absolute; left:70px; top:74px;
display:flex; flex-direction:row; gap:8px;
height:calc(100vh - 88px); z-index:20;
pointer-events:none;
}
.side-panel {
width:272px; height:100%; display:flex; flex-direction:column;
overflow:hidden; pointer-events:all; animation:fadeIn 0.15s ease;
flex-shrink:0;
}
.side-panel-header {
display:flex; align-items:center; gap:8px; padding:10px 12px;
border-bottom:1px solid var(--border); flex-shrink:0;
font-size:12px; font-weight:600;
}
.side-panel-header .tab-count {
background:rgba(56,189,248,0.1); color:var(--accent-out);
font-size:9px; padding:1px 5px; border-radius:8px; font-family:var(--mono);
}
.side-panel-header .close-btn { margin-left:auto; }
/* ── Search ── */
.search-wrap { display:flex; align-items:center; gap:6px; margin:7px 8px 3px; padding:6px 9px; background:rgba(255,255,255,0.03); border:1px solid var(--border); border-radius:7px; flex-shrink:0; transition:border-color 0.15s; }
.search-wrap:focus-within { border-color:var(--border-h); }
.search-icon { font-size:13px; color:var(--text-dim); flex-shrink:0; }
.search-input { flex:1; background:none; border:none; outline:none; font-family:var(--ui); font-size:11px; color:var(--text); }
.search-input::placeholder { color:var(--text-dim); }
.search-clear { background:none; color:var(--text-dim); font-size:10px; }
.search-clear:hover { color:var(--text); }
/* ── Device list ── */
.device-list { overflow-y:auto; flex:1; padding:3px 8px 8px; display:flex; flex-direction:column; gap:2px; }
.device-item { display:flex; align-items:center; gap:8px; padding:7px 8px; border-radius:7px; background:transparent; border:1px solid transparent; color:var(--text); text-align:left; width:100%; transition:all 0.12s; }
.device-item:hover { background:rgba(255,255,255,0.03); border-color:var(--border); }
.device-item.selected { background:rgba(56,189,248,0.07); border-color:rgba(56,189,248,0.28); }
.all-devices { border-style:dashed; border-color:var(--border); margin-bottom:3px; }
.all-devices.selected { border-style:solid; }
.device-icon { font-size:15px; flex-shrink:0; }
.device-info { flex:1; min-width:0; display:flex; flex-direction:column; gap:1px; }
.device-name { font-size:11px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.device-sub { font-size:9px; color:var(--text-dim); }
.device-traffic { display:flex; flex-direction:column; align-items:flex-end; gap:1px; font-size:9px; font-family:var(--mono); flex-shrink:0; }
/* ── Flow log ── */
.flow-log { overflow-y:auto; flex:1; padding:5px 8px; display:flex; flex-direction:column; gap:2px; }
.flow-entry { display:flex; align-items:center; gap:7px; padding:5px 7px; border-radius:6px; background:rgba(255,255,255,0.02); border:1px solid transparent; font-size:11px; transition:background 0.1s; }
.flow-entry:hover { background:rgba(255,255,255,0.05); }
.flow-entry.flow-blocked { border-color:rgba(204,34,0,0.2); background:rgba(204,34,0,0.05); }
.flow-dir { font-size:12px; font-weight:700; flex-shrink:0; width:13px; text-align:center; }
.flow-dir.out { color:var(--accent-out); }
.flow-dir.in { color:var(--accent-in); }
.flow-dir.blocked { color:var(--blocked); }
.flow-detail { flex:1; min-width:0; display:flex; flex-direction:column; gap:1px; }
.flow-device { font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:11px; }
.flow-dest { font-size:9px; color:var(--text-dim); }
.flow-ip { font-size:8px; color:var(--text-dim); opacity:0.6; }
.flow-bytes { font-size:9px; color:var(--text-dim); flex-shrink:0; font-family:var(--mono); }
/* ── Top talkers ── */
.talkers-sub { font-size:9px; color:var(--text-dim); font-weight:400; }
.talker-list { padding:8px; display:flex; flex-direction:column; gap:8px; overflow-y:auto; flex:1; }
.talker-row { display:flex; align-items:center; gap:8px; }
.talker-rank { font-size:10px; color:var(--text-dim); font-family:var(--mono); width:12px; flex-shrink:0; }
.talker-info { flex:1; min-width:0; }
.talker-name { font-size:11px; font-weight:500; display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:4px; }
.talker-bar-wrap { height:2px; background:rgba(255,255,255,0.06); border-radius:1px; }
.talker-bar { height:100%; background:var(--accent-out); border-radius:1px; transition:width 0.5s ease; }
.talker-bytes { display:flex; flex-direction:column; align-items:flex-end; gap:1px; font-size:9px; font-family:var(--mono); flex-shrink:0; }
/* ── Arc popup ── */
.arc-popup { position:absolute; top:74px; right:14px; width:240px; padding:13px; z-index:30; animation:fadeIn 0.15s ease; }
.arc-popup-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:11px; }
.arc-popup-dir { font-size:11px; font-weight:600; letter-spacing:0.05em; }
.arc-row { display:flex; justify-content:space-between; align-items:baseline; padding:4px 0; border-bottom:1px solid rgba(255,255,255,0.04); font-size:11px; }
.arc-row:last-child { border-bottom:none; }
.arc-label { color:var(--text-dim); flex-shrink:0; margin-right:8px; }
.arc-value { font-weight:500; text-align:right; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:160px; }
/* ── Device detail ── */
.device-detail { position:absolute; bottom:14px; right:14px; width:230px; padding:13px; z-index:20; animation:fadeIn 0.15s ease; }
.detail-header { display:flex; align-items:center; gap:9px; margin-bottom:10px; }
.detail-icon { font-size:20px; }
.detail-name { font-size:12px; font-weight:600; }
.detail-mac { font-size:9px; color:var(--text-dim); font-family:var(--mono); }
.detail-grid { display:flex; flex-direction:column; gap:5px; }
.detail-row { display:flex; justify-content:space-between; align-items:center; font-size:11px; }
.detail-label { color:var(--text-dim); }
.detail-value { font-weight:500; }
/* ── Shared close btn ── */
.close-btn { background:rgba(255,255,255,0.04); border:1px solid var(--border); color:var(--text-dim); border-radius:5px; width:21px; height:21px; font-size:9px; display:flex; align-items:center; justify-content:center; transition:all 0.12s; flex-shrink:0; }
.close-btn:hover { color:var(--text); }
/* ── Color picker ── */
.color-picker { position:absolute; bottom:60px; right:14px; width:240px; padding:14px; z-index:30; animation:fadeIn 0.15s ease; }
.picker-header { display:flex; align-items:center; gap:8px; margin-bottom:12px; }
.picker-title { font-size:12px; font-weight:600; flex:1; }
.picker-reset { font-size:10px; padding:3px 8px; border-radius:6px; background:rgba(255,255,255,0.06); border:1px solid var(--border); color:var(--text-dim); }
.picker-reset:hover { color:var(--text); }
.picker-rows { display:flex; flex-direction:column; gap:8px; margin-bottom:14px; }
.color-row { display:flex; align-items:center; justify-content:space-between; }
.color-label { font-size:11px; color:var(--text-dim); }
.color-input-wrap { display:flex; align-items:center; gap:7px; }
.color-native { width:28px; height:22px; padding:0; border:1px solid var(--border); border-radius:5px; background:none; cursor:pointer; }
.color-native::-webkit-color-swatch-wrapper { padding:2px; }
.color-native::-webkit-color-swatch { border:none; border-radius:3px; }
.color-hex { font-size:10px; color:var(--text-dim); width:54px; }
.picker-preset-label { font-size:9px; text-transform:uppercase; letter-spacing:0.1em; color:var(--text-dim); display:block; margin-bottom:7px; }
.preset-btns { display:flex; flex-wrap:wrap; gap:5px; }
.preset-btn { display:flex; align-items:center; gap:5px; padding:4px 9px; border-radius:6px; font-size:10px; background:rgba(255,255,255,0.04); border:1px solid var(--border); color:var(--text-dim); transition:all 0.12s; }
.preset-btn:hover { color:var(--text); border-color:var(--border-h); }
.preset-swatch { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
/* ── Floating draggable panels ── */
.floating-panel {
position: absolute;
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 25;
animation: fadeIn 0.15s ease;
min-width: 220px;
min-height: 160px;
}
.fp-header {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 12px;
border-bottom: 1px solid var(--border);
cursor: grab;
flex-shrink: 0;
user-select: none;
}
.fp-header:active { cursor: grabbing; }
.fp-title {
font-size: 12px;
font-weight: 600;
flex: 1; /* takes all space, pushes everything else right */
}
/* badge / sub-label next to title */
.fp-header .tab-count,
.fp-header .talkers-sub {
flex-shrink: 0;
}
/* export button — same height as close btn */
.export-btn-sm {
font-size: 9px;
padding: 0 8px;
height: 21px;
line-height: 1;
border-radius: 5px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
color: var(--text-dim);
transition: all 0.15s;
flex-shrink: 0;
display: flex;
align-items: center;
}
.export-btn-sm:hover { color: var(--text); }
.fp-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.fp-resize {
position: absolute;
bottom: 2px;
right: 4px;
font-size: 14px;
color: var(--text-dim);
cursor: se-resize;
opacity: 0.4;
line-height: 1;
user-select: none;
}
.fp-resize:hover { opacity: 0.9; }
/* fp-body children that need to scroll */
.fp-body .device-list,
.fp-body .flow-log,
.fp-body .talker-list {
flex: 1;
overflow-y: auto;
}
/* search inside floating panel */
.fp-body .search-wrap { flex-shrink: 0; }
/* ── Protocol breakdown ── */
.proto-list { padding:10px 12px; display:flex; flex-direction:column; gap:10px; overflow-y:auto; flex:1; }
.proto-row { display:flex; flex-direction:column; gap:4px; }
.proto-name { font-size:12px; font-weight:600; }
.proto-bar-wrap { height:4px; background:rgba(255,255,255,0.06); border-radius:2px; }
.proto-bar { height:100%; border-radius:2px; transition:width 0.4s ease; }
.proto-stats { display:flex; gap:10px; font-size:10px; color:var(--text-dim); }
.proto-pct { min-width:36px; }
.proto-bytes { font-family:var(--mono); }
.proto-count { font-family:var(--mono); opacity:0.6; }
/* ── Replay bar ── */
.replay-bar {
position:absolute; bottom:20px; left:50%; transform:translateX(-50%);
padding:10px 16px; z-index:30; min-width:480px;
animation:fadeIn 0.15s ease;
}
.replay-header { display:flex; align-items:center; gap:12px; margin-bottom:8px; font-size:11px; }
.replay-label { font-weight:600; color:var(--accent-out); }
.replay-time { color:var(--text); }
.replay-count { color:var(--text-dim); flex:1; }
.replay-live { padding:3px 10px; border-radius:6px; background:rgba(56,189,248,0.1); border:1px solid rgba(56,189,248,0.3); color:var(--accent-out); font-size:10px; }
.replay-live:hover { background:rgba(56,189,248,0.2); }
.replay-controls { display:flex; align-items:center; gap:8px; }
.replay-btn { width:28px; height:28px; border-radius:6px; background:rgba(255,255,255,0.06); border:1px solid var(--border); color:var(--text); font-size:13px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.replay-btn:hover { border-color:var(--border-h); color:var(--accent-out); }
.replay-slider { flex:1; accent-color:var(--accent-out); cursor:pointer; }
.replay-pos { font-size:10px; color:var(--text-dim); min-width:32px; text-align:right; }
/* ── Dock divider ── */
.dock-divider { height:1px; background:var(--border); margin:4px 6px; }
/* ── Heatmap legend ── */
.heatmap-legend {
position:absolute; bottom:20px; right:70px;
padding:10px 14px; z-index:20; min-width:180px;
animation:fadeIn 0.15s ease;
}
.heatmap-title { font-size:10px; font-weight:600; color:var(--text-dim); text-transform:uppercase; letter-spacing:0.08em; margin-bottom:8px; }
.heatmap-scale { display:flex; align-items:center; gap:8px; margin-bottom:6px; }
.heatmap-min, .heatmap-max { font-size:10px; font-family:var(--mono); color:var(--text-dim); flex-shrink:0; }
.heatmap-gradient {
flex:1; height:6px; border-radius:3px;
background: linear-gradient(to right, rgba(0,40,60,0.4), rgba(0,130,150,0.6), rgba(0,200,220,0.85));
}
.heatmap-top { font-size:10px; color:var(--text-dim); }
.heatmap-top strong { color:var(--accent-out); }
.heatmap-hover { font-size:10px; color:var(--text); margin-top:4px; font-family:var(--mono); }
/* ── Traffic timeline ── */
.timeline {
position:absolute; bottom:0; left:0; right:0;
padding:5px 14px 5px; z-index:15;
border-radius:0; border-left:none; border-right:none; border-bottom:none;
background:rgba(4,8,16,0.92);
user-select:none;
}
.timeline-header {
display:flex; align-items:center; gap:12px; margin-bottom:3px;
}
.timeline-label { font-size:9px; color:var(--text-dim); text-transform:uppercase; letter-spacing:0.08em; flex:1; }
.timeline-stats { display:flex; gap:12px; font-size:9px; font-family:var(--mono); }
.timeline-stat-out { color:var(--accent-out); }
.timeline-stat-in { color:var(--accent-in); }
.timeline-toggles { display:flex; gap:4px; }
.timeline-toggle {
padding:1px 7px; border-radius:10px; font-size:9px; font-weight:500;
background:rgba(255,255,255,0.04); border:1px solid var(--border);
color:var(--text-dim); transition:all 0.15s; cursor:pointer;
}
.timeline-toggle:hover { color:var(--text); }
.timeline-toggle.on-out { background:rgba(56,189,248,0.1); color:var(--accent-out); border-color:rgba(56,189,248,0.3); }
.timeline-toggle.on-in { background:rgba(244,114,182,0.1); color:var(--accent-in); border-color:rgba(244,114,182,0.3); }
.timeline-wrap { position:relative; }
.timeline-svg { display:block; width:100%; cursor:crosshair; }
.timeline-tooltip {
position:absolute; top:-36px; transform:translateX(-50%);
background:var(--panel-bg); border:1px solid var(--border);
padding:3px 8px; border-radius:6px; font-size:9px; font-family:var(--mono);
white-space:nowrap; pointer-events:none; z-index:5;
}
.timeline-cursor { position:absolute; top:0; bottom:0; width:1px; background:rgba(255,255,255,0.3); pointer-events:none; }
/* ── Country filter badge ── */
.country-badge {
position:absolute; top:74px; left:50%; transform:translateX(-50%);
display:flex; align-items:center; gap:10px;
padding:6px 12px; z-index:25; font-size:11px;
animation:fadeIn 0.15s ease;
}
.country-badge strong { color:var(--accent-out); }
.country-badge-count { color:var(--text-dim); font-size:10px; }
/* ── Device group dot ── */
.group-dot { display:inline-block; width:6px; height:6px; border-radius:50%; margin-right:5px; vertical-align:middle; flex-shrink:0; }
/* ── Groups panel ── */
.groups-body { display:flex; flex-direction:column; flex:1; overflow:hidden; }
.group-filters { display:flex; flex-wrap:wrap; gap:4px; padding:8px; flex-shrink:0; border-bottom:1px solid var(--border); }
.group-filter-btn { padding:3px 10px; border-radius:20px; font-size:10px; font-weight:500; background:rgba(255,255,255,0.04); border:1px solid var(--border); color:var(--text-dim); transition:all 0.15s; }
.group-filter-btn:hover { color:var(--text); }
.group-filter-btn.active { background:rgba(56,189,248,0.1); color:var(--accent-out); border-color:rgba(56,189,248,0.3); }
.group-count { font-size:8px; font-family:var(--mono); background:rgba(255,255,255,0.1); border-radius:8px; padding:0 4px; margin-left:3px; }
.group-device-list { overflow-y:auto; flex:1; padding:4px 8px; display:flex; flex-direction:column; gap:3px; }
.group-device-row { display:flex; align-items:center; gap:8px; padding:5px 6px; border-radius:6px; transition:background 0.1s; }
.group-device-row:hover { background:rgba(255,255,255,0.03); }
.group-device-info { flex:1; min-width:0; display:flex; flex-direction:column; gap:1px; }
.group-select {
background:rgba(255,255,255,0.06); border:1px solid var(--border);
color:var(--text-dim); border-radius:5px; font-size:9px; padding:2px 4px;
cursor:pointer; flex-shrink:0; font-family:var(--ui);
}
.group-select:hover { border-color:var(--border-h); }
.group-select option { background:#0d1829; }
/* ── Misc ── */
.geoip-warning { position:absolute; bottom:14px; left:50%; transform:translateX(-50%); padding:7px 14px; font-size:11px; color:var(--yellow); border-color:rgba(251,191,36,0.15); z-index:20; white-space:nowrap; }
.empty-state { color:var(--text-dim); font-size:11px; text-align:center; padding:24px 12px; }
+7
View File
@@ -0,0 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
+22
View File
@@ -0,0 +1,22 @@
import { useState, useCallback } from 'react';
export function useLocalStorage(key, defaultValue) {
const [value, setValueState] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : defaultValue;
} catch {
return defaultValue;
}
});
const setValue = useCallback((newVal) => {
setValueState(prev => {
const next = typeof newVal === 'function' ? newVal(prev) : newVal;
try { localStorage.setItem(key, JSON.stringify(next)); } catch {}
return next;
});
}, [key]);
return [value, setValue];
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useRef, useState, useCallback } from 'react';
const WS_URL = import.meta.env.VITE_WS_URL ||
`${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ws`;
export function useNetMapSocket() {
const [connected, setConnected] = useState(false);
const [devices, setDevices] = useState([]);
const [flows, setFlows] = useState([]);
const [home, setHome] = useState({ lat: 59.9139, lon: 10.7522, name: 'Home' });
const [geoipLoaded, setGeoipLoaded] = useState(true);
const wsRef = useRef(null);
const reconnectTimer = useRef(null);
const flowIdSet = useRef(new Set());
const addFlows = useCallback((newFlows) => {
const fresh = newFlows.filter(f => !flowIdSet.current.has(f.id));
fresh.forEach(f => flowIdSet.current.add(f.id));
// Keep flowId set from growing unbounded
if (flowIdSet.current.size > 2000) {
const arr = [...flowIdSet.current];
arr.slice(0, 500).forEach(id => flowIdSet.current.delete(id));
}
if (fresh.length === 0) return;
setFlows(prev => {
const combined = [...prev, ...fresh];
// Keep last 300 flows in state
return combined.slice(-300);
});
}, []);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(WS_URL);
wsRef.current = ws;
ws.onopen = () => {
setConnected(true);
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'init') {
setHome(msg.home);
setDevices(msg.devices || []);
setGeoipLoaded(msg.geoip_loaded !== false);
if (msg.recent_flows) addFlows(msg.recent_flows);
} else if (msg.type === 'devices') {
setDevices(msg.devices || []);
} else if (msg.type === 'flows') {
addFlows(msg.flows || []);
}
} catch (_) {
}
};
ws.onclose = () => {
setConnected(false);
reconnectTimer.current = setTimeout(connect, 3000);
};
ws.onerror = () => ws.close();
}, [addFlows]);
useEffect(() => {
connect();
return () => {
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
wsRef.current?.close();
};
}, [connect]);
return { connected, devices, flows, home, geoipLoaded };
}
+87
View File
@@ -0,0 +1,87 @@
export function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
export function formatUptime(seconds) {
if (!seconds) return '—';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export function protoName(n) {
const PROTOS = { 1:'ICMP', 2:'IGMP', 6:'TCP', 17:'UDP', 41:'IPv6', 47:'GRE', 50:'ESP', 51:'AH', 58:'ICMPv6', 89:'OSPF', 132:'SCTP' };
return PROTOS[n] || (n ? `IP/${n}` : '—');
}
export function protoColor(n) {
const COLORS = { 6:'#38bdf8', 17:'#a78bfa', 1:'#fbbf24', 58:'#fb923c' };
return COLORS[n] || '#64748b';
}
export function serviceLabel(port, proto) {
const TCP = {
20:'FTP-data', 21:'FTP', 22:'SSH', 23:'Telnet', 25:'SMTP', 53:'DNS',
80:'HTTP', 110:'POP3', 143:'IMAP', 443:'HTTPS', 465:'SMTPS', 587:'SMTP',
993:'IMAPS', 995:'POP3S', 1194:'OpenVPN', 1883:'MQTT', 3306:'MySQL',
3389:'RDP', 5900:'VNC', 6443:'K8s API', 8080:'HTTP-alt', 8443:'HTTPS-alt',
8883:'MQTT-TLS', 9000:'Custom', 51820:'WireGuard',
};
const UDP = {
53:'DNS', 67:'DHCP', 68:'DHCP', 123:'NTP', 161:'SNMP', 500:'IKE',
514:'Syslog', 1194:'OpenVPN', 4500:'IPsec', 5353:'mDNS', 51820:'WireGuard',
};
if (proto === 6 && TCP[port]) return TCP[port];
if (proto === 17 && UDP[port]) return UDP[port];
if (TCP[port]) return TCP[port];
return port ? `port ${port}` : '';
}
export const DEVICE_GROUPS = ['IoT', 'Servers', 'Mobile', 'Network', 'Desktop', 'Media'];
export const GROUP_COLORS = {
IoT:'#f59e0b', Servers:'#3b82f6', Mobile:'#ec4899',
Network:'#10b981', Desktop:'#8b5cf6', Media:'#f97316',
};
export function timeAgo(ms) {
if (!ms) return '—';
const sec = Math.floor((Date.now() - ms) / 1000);
if (sec < 10) return 'just now';
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
return `${Math.floor(min / 60)}h ago`;
}
export function deviceIcon(device) {
if (device.is_wired) return '🖥';
const oui = (device.oui || '').toLowerCase();
if (oui.includes('apple')) return '🍎';
if (oui.includes('samsung')) return '📱';
if (oui.includes('sony')) return '🎮';
if (oui.includes('lg')) return '📺';
if (oui.includes('intel')) return '💻';
if (oui.includes('raspberry')) return '🍓';
return '📡';
}
// Map app/category codes to readable names
const APP_NAMES = {
0: 'Other', 1: 'Instant messaging', 2: 'P2P', 3: 'Thumbnails',
4: 'Streaming media', 5: 'VoIP', 6: 'Gaming', 7: 'Remote access',
8: 'News feeds', 9: 'Streaming video', 10: 'Email', 11: 'News',
12: 'Downloads', 13: 'Business apps', 14: 'Social networking',
15: 'Web browsing', 255: 'Unknown',
};
export function appName(cat) {
if (typeof cat === 'number') return APP_NAMES[cat] || `Cat ${cat}`;
return cat || 'Unknown';
}
export const OUT_COLOR = '#38bdf8';
export const IN_COLOR = '#f472b6';
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'build',
},
server: {
port: 3000,
},
esbuild: {
loader: 'jsx',
include: /src\/.*\.[jt]sx?$/,
},
})