"use client" import { useState, useRef, useEffect } from "react" import { PageHeader } from "@/components/page-header" import { servers, greTunnels, type Server, type ServerType } from "@/lib/data" import { Button } from "@/components/ui/button" import { StatusBadge } from "@/components/status-badge" import { DownloadIcon, XIcon, LockIcon, LockOpenIcon, WifiIcon, ZoomInIcon, ZoomOutIcon, Maximize2Icon, SearchIcon, LayersIcon, MapIcon, HomeIcon, TerminalIcon, ShieldIcon, CableIcon, CopyIcon, ActivityIcon, ExternalLinkIcon, } from "lucide-react" import { cn } from "@/lib/utils" // ─── Resource metrics (mirrors uptime/page.tsx INIT_RESOURCES formula) ─────── interface SrvRes { cpu: number cpuHistory: number[] ramUsed: number; ramTotal: number hddUsed: number; hddTotal: number uptimeSeconds: number temp?: number } const BOARD_MAP: Record = { "jump-host": "RB5009UG+S+IN", "exit-node": "RB4011iGS+RM", "home-router": "hAP ax²", } const SERVER_RESOURCES: Record = Object.fromEntries( servers.map(s => { const h = s.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0) const cpu = 4 + (h % 68) const ramTotal = s.type === "jump-host" ? 8192 : s.type === "exit-node" ? 4096 : 1024 const hddTotal = s.type === "home-router" ? 2048 : 16384 const ramPct = 12 + (h % 72) const hddPct = 8 + (h % 75) return [s.id, { cpu, cpuHistory: Array.from({ length: 32 }, (_, i) => Math.max(1, Math.min(99, cpu + Math.round(Math.sin(i * 0.7 + h * 0.1) * 15))), ), ramUsed: Math.round(ramTotal * ramPct / 100), ramTotal, hddUsed: Math.round(hddTotal * hddPct / 100), hddTotal, uptimeSeconds: (1 + h % 200) * 86400 + (h % 24) * 3600 + (h % 60) * 60, temp: s.type !== "home-router" ? 34 + (h % 32) : undefined, } satisfies SrvRes] }) ) function fmtMB(mb: number): string { if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ` return `${mb} МБ` } function fmtUptime(sec: number): string { const d = Math.floor(sec / 86400) const h = Math.floor((sec % 86400) / 3600) const m = Math.floor((sec % 3600) / 60) if (d > 0) return `${d}д ${h}ч` if (h > 0) return `${h}ч ${m}м` return `${m}м` } function resBarColor(pct: number): string { if (pct >= 85) return "bg-red-500" if (pct >= 70) return "bg-amber-500" return "bg-emerald-500" } function resPctColor(pct: number): string { if (pct >= 85) return "text-red-400" if (pct >= 70) return "text-amber-400" return "text-emerald-400" } function MiniBar({ pct }: { pct: number }) { return (
) } function Sparkline({ history }: { history: number[] }) { const max = Math.max(...history, 1) const W = 80, H = 24 const pts = history.map((v, i) => { const x = (i / (history.length - 1)) * W const y = H - (v / max) * H return `${x},${y}` }).join(" ") return ( ) } // ─── Canvas dimensions ──────────────────────────────────────────────────────── const W = 1060 const H = 580 const ZOOM_MIN = 0.2 const ZOOM_MAX = 6 // ─── Visual config ──────────────────────────────────────────────────────────── const STATUS_STYLE = { online: { fill: "#0f2d1f", stroke: "#4ade80", text: "#4ade80", glow: "rgba(74,222,128,0.15)" }, degraded: { fill: "#2d1e06", stroke: "#fbbf24", text: "#fbbf24", glow: "rgba(251,191,36,0.15)" }, offline: { fill: "#2d0f0f", stroke: "#f87171", text: "#f87171", glow: "transparent" }, } const TYPE_STYLE: Record = { "jump-host": { label: "JH", fill: "#7c3aed", r: 36 }, "exit-node": { label: "EN", fill: "#0369a1", r: 36 }, "home-router": { label: "HR", fill: "#16a34a", r: 30 }, } const WAN_COLORS = ["#0ea5e9", "#f97316", "#a855f7", "#ec4899", "#14b8a6"] const TUNNEL_STYLE = { up: { stroke: "#4ade80", opacity: 0.5, dotOpacity: 0.85 }, degraded: { stroke: "#fbbf24", opacity: 0.4, dotOpacity: 0.7 }, down: { stroke: "#f87171", opacity: 0.15, dotOpacity: 0 }, } // ─── Positions ──────────────────────────────────────────────────────────────── const POS: Record = { srv1: { x: 500, y: 170 }, srv2: { x: 130, y: 75 }, srv3: { x: 820, y: 75 }, srv4: { x: 910, y: 265 }, srv5: { x: 700, y: 385 }, srv6: { x: 310, y: 345 }, srv7: { x: 125, y: 255 }, home1: { x: 270, y: 470 }, home2: { x: 790, y: 470 }, } const WAN_SAT_POS: Record = { home1: [{ x: 155, y: 510 }, { x: 385, y: 510 }], home2: [{ x: 665, y: 510 }, { x: 915, y: 510 }], } // ─── WAN→JH edges ──────────────────────────────────────────────────────────── interface WanJhEdge { homeId: string; wanIdx: number; jhId: string pingMs: number; dlMbps: number; active: boolean } const WAN_JH_EDGES: WanJhEdge[] = [ { homeId: "home1", wanIdx: 0, jhId: "srv1", pingMs: 4, dlMbps: 480, active: true }, { homeId: "home1", wanIdx: 0, jhId: "srv7", pingMs: 6, dlMbps: 480, active: true }, { homeId: "home1", wanIdx: 1, jhId: "srv1", pingMs: 12, dlMbps: 95, active: false }, { homeId: "home2", wanIdx: 0, jhId: "srv1", pingMs: 18, dlMbps: 280, active: true }, { homeId: "home2", wanIdx: 0, jhId: "srv7", pingMs: 20, dlMbps: 278, active: false }, { homeId: "home2", wanIdx: 1, jhId: "srv1", pingMs: 22, dlMbps: 185, active: false }, ] // ─── Tunnel probes ──────────────────────────────────────────────────────────── interface TunnelProbe { pingMs: number | null; dlMbps: number | null; ulMbps: number | null } const TUNNEL_PROBES: Record = { gre1: { pingMs: 28, dlMbps: 614, ulMbps: 421 }, gre2: { pingMs: 41, dlMbps: 488, ulMbps: 352 }, gre3: { pingMs: 88, dlMbps: 143, ulMbps: 104 }, gre4: { pingMs: null, dlMbps: null, ulMbps: null }, gre5: { pingMs: 14, dlMbps: 578, ulMbps: 412 }, gre6: { pingMs: 92, dlMbps: 155, ulMbps: 112 }, } const TYPE_LABELS: Record = { "jump-host": "JumpHost", "exit-node": "Exit Node", "home-router": "Home Router", } // ─── Helpers ────────────────────────────────────────────────────────────────── function pingColor(ms: number | null) { if (ms == null) return "#f87171" if (ms < 15) return "#4ade80" if (ms < 50) return "#fbbf24" return "#fb923c" } function latencyColor(ms: number | null) { if (ms == null) return "#f87171" if (ms > 60) return "#fbbf24" return "#4ade80" } function shortName(name: string) { return name.replace(/^(home|mt)-/, "").split("-").slice(0, 3).join("-") } // ─── Filter ─────────────────────────────────────────────────────────────────── type FilterKey = "all" | "online" | "degraded" | "offline" | "jump-host" | "exit-node" | "home-router" // ─── SVG sub-components ─────────────────────────────────────────────────────── function PingBadge({ mx, my, ping, dl, ul, color }: { mx: number; my: number ping: number | null; dl?: number | null; ul?: number | null color: string }) { const hasSpeed = dl != null && dl > 0 const h = hasSpeed ? 36 : 20 return ( {ping == null ? "timeout" : `${ping} мс`} {hasSpeed && ( {`↓${dl} ↑${ul}`} )} ) } function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) { const ss = STATUS_STYLE[n.status] const ts = TYPE_STYLE[n.type] const lc = latencyColor(n.latency) const ox = n.x + ts.r + 14 const oy = n.y - 32 const lines = [ n.name, `${TYPE_LABELS[n.type]} · ${n.site}`, n.latency != null ? `${n.latency} мс` : "Офлайн", n.model ?? "", ].filter(Boolean) const bw = 148, bh = lines.length * 14 + 14 return ( {/* colour accent line */} {lines.map((l, i) => ( {l} ))} ) } function ServerNode({ n, isSel, isVis, isDragged, onClick, onDblClick, onHoverChange, onContextMenu, onMouseDown }: { n: Server & { x: number; y: number } isSel: boolean; isVis: boolean; isDragged: boolean onClick: () => void onDblClick: () => void onHoverChange: (hov: boolean) => void onContextMenu: (e: React.MouseEvent) => void onMouseDown: (e: React.MouseEvent) => void }) { const ss = STATUS_STYLE[n.status] const ts = TYPE_STYLE[n.type] const lc = latencyColor(n.latency) return ( { e.stopPropagation(); onMouseDown(e) }} onClick={e => { e.stopPropagation(); onClick() }} onDoubleClick={e => { e.stopPropagation(); onDblClick() }} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); onContextMenu(e) }} onMouseEnter={() => onHoverChange(true)} onMouseLeave={() => onHoverChange(false)} > {n.status === "online" && isVis && ( )} {n.status === "degraded" && isVis && ( )} {isSel && ( )} {ts.label} 32 ? "13" : "11"} fontWeight="700" fill="#f1f5f9" fontFamily="ui-monospace,monospace">{n.site} {n.latency == null ? "офлайн" : `${n.latency} мс`} {shortName(n.name)} ) } function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: { x: number; y: number wan: { name: string; isp: string; maxDl: number; maxUl: number } color: string; active: boolean; isSel: boolean; isDragged: boolean onSelect: () => void onMouseDown: (e: React.MouseEvent) => void }) { const r = 18 return ( { e.stopPropagation(); onMouseDown(e) }} onClick={e => { e.stopPropagation(); onSelect() }}> {active && ( )} {isSel && ( )} {wan.name} {wan.isp.length > 10 ? wan.isp.slice(0, 9) + "…" : wan.isp} ↓{wan.maxDl} ↑{wan.maxUl} ) } // ─── Context menu ───────────────────────────────────────────────────────────── interface CtxMenu { x: number; y: number; server: Server } function ContextMenu({ menu, onClose }: { menu: CtxMenu; onClose: () => void }) { const items = [ { icon: , label: "Открыть терминал", href: `/terminal?host=${menu.server.host}`, }, { icon: , label: "Firewall", href: `/firewall?server=${menu.server.id}`, }, { icon: , label: "GRE-туннели", href: `/gre?server=${menu.server.id}`, }, { icon: , label: "Трафик", href: `/traffic?server=${menu.server.id}`, }, ] return ( <> {/* backdrop */}
{/* header */}
{menu.server.name} {menu.server.host}
{items.map(item => ( {item.icon} {item.label} ))}
) } // ─── Minimap ────────────────────────────────────────────────────────────────── const MM_W = 172, MM_H = 94 function Minimap({ pan, zoom, nodes, greEdges, satPos, onClose, onPan }: { pan: { x: number; y: number }; zoom: number nodes: (Server & { x: number; y: number })[] greEdges: { tunnel: typeof greTunnels[0]; from: typeof nodes[0]; to: typeof nodes[0] }[] satPos: Record onClose: () => void onPan: (x: number, y: number) => void }) { const vpW = Math.min(W, W / zoom) const vpH = Math.min(H, H / zoom) function handleClick(e: React.MouseEvent) { e.stopPropagation() const rect = e.currentTarget.getBoundingClientRect() const svgX = (e.clientX - rect.left) / rect.width * W const svgY = (e.clientY - rect.top) / rect.height * H onPan(svgX - vpW / 2, svgY - vpH / 2) } return (
{/* simplified edges */} {greEdges.map(e => ( ))} {/* WAN edges */} {WAN_JH_EDGES.map((edge, i) => { const sat = satPos[edge.homeId]?.[edge.wanIdx] const jh = nodes.find(n => n.id === edge.jhId) if (!sat || !jh) return null return })} {/* nodes */} {nodes.map(n => ( ))} {/* WAN satellites */} {servers.filter(s => s.type === "home-router").flatMap(r => (satPos[r.id] ?? []).map((p, i) => ( )) )} {/* viewport rect */} minimap
) } // ─── Zoom controls overlay ──────────────────────────────────────────────────── function ZoomControls({ zoom, onZoomIn, onZoomOut, onFit }: { zoom: number onZoomIn: () => void onZoomOut: () => void onFit: () => void }) { return (
{Math.round(zoom * 100)}%
) } // ════════════════════════════════════════════════════════════════════════════ export default function NetworkMapPage() { // ── View state ────────────────────────────────────────────────────────────── const [pan, setPan] = useState({ x: 0, y: 0 }) const [zoom, setZoom] = useState(1) const [isDragging, setIsDragging] = useState(false) // ── Interaction ───────────────────────────────────────────────────────────── const [selected, setSelected] = useState(null) const [selWanIdx, setSelWanIdx] = useState(null) const [hoveredId, setHoveredId] = useState(null) // ── Node positions (overrides POS defaults) ───────────────────────────────── const [nodePositions, setNodePositions] = useState>({}) const [satPositions, setSatPositions] = useState>({}) // ── Context menu ──────────────────────────────────────────────────────────── const [ctxMenu, setCtxMenu] = useState(null) // ── Toggles ───────────────────────────────────────────────────────────────── const [filter, setFilter] = useState("all") const [search, setSearch] = useState("") const [showPingBadges, setShowPingBadges] = useState(true) const [showAnimDots, setShowAnimDots] = useState(true) const [showMinimap, setShowMinimap] = useState(true) const [showHints, setShowHints] = useState(false) const [showLayers, setShowLayers] = useState(false) // ── Refs ───────────────────────────────────────────────────────────────────── const svgRef = useRef(null) // Canvas pan drag const dragRef = useRef<{ startX: number; startY: number panX: number; panY: number moved: boolean } | null>(null) // Node / WAN-sat drag type NodeDrag = | { kind: "server"; nodeId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean } | { kind: "wan-sat"; homeId: string; wanIdx: number; startX: number; startY: number; origX: number; origY: number; moved: boolean } const nodeDragRef = useRef(null) const suppressClickRef = useRef(false) const [draggedNodeId, setDraggedNodeId] = useState(null) const [draggedSatKey, setDraggedSatKey] = useState(null) // `${homeId}-${wanIdx}` const zoomRef = useRef(zoom) const panRef = useRef(pan) zoomRef.current = zoom panRef.current = pan // ── SVG export ─────────────────────────────────────────────────────────── function exportSvg() { const svg = svgRef.current if (!svg) return // Clone with full viewBox (reset pan/zoom) const clone = svg.cloneNode(true) as SVGSVGElement clone.setAttribute("viewBox", `0 0 ${W} ${H}`) clone.setAttribute("width", String(W)) clone.setAttribute("height", String(H)) const blob = new Blob( [`\n`, clone.outerHTML], { type: "image/svg+xml;charset=utf-8" }, ) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = `network-map-${new Date().toISOString().slice(0, 10)}.svg` a.click() URL.revokeObjectURL(url) } // ── Non-passive wheel zoom ──────────────────────────────────────────────── useEffect(() => { const svg = svgRef.current if (!svg) return const handler = (e: WheelEvent) => { e.preventDefault() const z = zoomRef.current const p = panRef.current const rect = svg.getBoundingClientRect() const svgX = p.x + (e.clientX - rect.left) / rect.width * (W / z) const svgY = p.y + (e.clientY - rect.top) / rect.height * (H / z) // trackpad pinch sends ctrlKey, use smaller step const step = e.ctrlKey ? 1.06 : 1.14 const factor = e.deltaY < 0 ? step : 1 / step const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z * factor)) setPan({ x: svgX - (e.clientX - rect.left) / rect.width * (W / newZoom), y: svgY - (e.clientY - rect.top) / rect.height * (H / newZoom), }) setZoom(newZoom) } svg.addEventListener("wheel", handler, { passive: false }) return () => svg.removeEventListener("wheel", handler) }, []) // ── Keyboard shortcuts ──────────────────────────────────────────────────── useEffect(() => { function onKey(e: KeyboardEvent) { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return if (e.key === "Escape") { setSelected(null); setSelWanIdx(null) } if (e.key === "=" || e.key === "+") applyZoomCenter(1.25) if (e.key === "-") applyZoomCenter(1 / 1.25) if (e.key === "0" || e.key.toLowerCase() === "f") fitView() if (e.key.toLowerCase() === "m") setShowMinimap(v => !v) if (e.key.toLowerCase() === "p") setShowPingBadges(v => !v) } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) }, [zoom, pan]) // re-register when zoom/pan change so closure has fresh values // ── Zoom helpers ────────────────────────────────────────────────────────── function applyZoomCenter(factor: number) { const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor)) setPan(p => ({ x: p.x + (W / zoom - W / newZoom) / 2, y: p.y + (H / zoom - H / newZoom) / 2, })) setZoom(newZoom) } function fitView() { setPan({ x: 0, y: 0 }) setZoom(1) } function focusNode(x: number, y: number) { const newZoom = Math.min(ZOOM_MAX, Math.max(zoom * 1.8, 2)) setPan({ x: x - W / (2 * newZoom), y: y - H / (2 * newZoom) }) setZoom(newZoom) } // ── Drag ───────────────────────────────────────────────────────────────── function onSvgMouseDown(e: React.MouseEvent) { // Only pan on background clicks (node mousedown stops propagation) dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y, moved: false } setIsDragging(true) } function onSvgMouseMove(e: React.MouseEvent) { const nd = nodeDragRef.current if (nd) { const dx = e.clientX - nd.startX const dy = e.clientY - nd.startY if (Math.abs(dx) > 2 || Math.abs(dy) > 2) nd.moved = true const rect = e.currentTarget.getBoundingClientRect() const svgDx = dx * (W / zoom) / rect.width const svgDy = dy * (H / zoom) / rect.height const nx = nd.origX + svgDx const ny = nd.origY + svgDy if (nd.kind === "server") { setNodePositions(prev => ({ ...prev, [nd.nodeId]: { x: nx, y: ny } })) } else { setSatPositions(prev => { const arr = [...(prev[nd.homeId] ?? (WAN_SAT_POS[nd.homeId] ?? []))] arr[nd.wanIdx] = { x: nx, y: ny } return { ...prev, [nd.homeId]: arr } }) } return } if (!dragRef.current) return const dx = e.clientX - dragRef.current.startX const dy = e.clientY - dragRef.current.startY if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragRef.current.moved = true const rect = e.currentTarget.getBoundingClientRect() setPan({ x: dragRef.current.panX - dx * (W / zoom / rect.width), y: dragRef.current.panY - dy * (H / zoom / rect.height), }) } function finishNodeDrag() { if (!nodeDragRef.current) return false suppressClickRef.current = nodeDragRef.current.moved nodeDragRef.current = null setDraggedNodeId(null) setDraggedSatKey(null) return true } function onSvgMouseUp() { if (finishNodeDrag()) return const moved = dragRef.current?.moved ?? false dragRef.current = null setIsDragging(false) if (!moved) { setSelected(null); setSelWanIdx(null) } } // ── Node drag start ────────────────────────────────────────────────────── function onNodeMouseDown(e: React.MouseEvent, n: Server & { x: number; y: number }) { nodeDragRef.current = { kind: "server", nodeId: n.id, startX: e.clientX, startY: e.clientY, origX: n.x, origY: n.y, moved: false } setDraggedNodeId(n.id) } function onSatMouseDown(e: React.MouseEvent, homeId: string, wanIdx: number, x: number, y: number) { nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false } setDraggedSatKey(`${homeId}-${wanIdx}`) } // ── Data ───────────────────────────────────────────────────────────────── const nodes = servers.map(s => { const pos = nodePositions[s.id] ?? POS[s.id] ?? { x: 0, y: 0 } return { ...s, ...pos } }) const nodeById = Object.fromEntries(nodes.map(n => [n.id, n])) // Satellite positions merging defaults with overrides const effectiveSatPos: Record = {} servers.filter(s => s.type === "home-router").forEach(r => { effectiveSatPos[r.id] = (WAN_SAT_POS[r.id] ?? []).map((def, i) => satPositions[r.id]?.[i] ?? def ) }) const greEdges = greTunnels.flatMap(t => { const from = nodes.find(n => n.id === t.serverId) const to = nodes.find(n => n.host === t.remoteAddress) if (!from || !to || from.type === "home-router" || to.type === "home-router") return [] return [{ tunnel: t, from, to }] }) // ── Visibility / search ────────────────────────────────────────────────── const sq = search.toLowerCase().trim() const matchesSearch = (n: typeof nodes[0]) => !sq || n.name.toLowerCase().includes(sq) || n.site.toLowerCase().includes(sq) || n.model?.toLowerCase().includes(sq) || n.os?.toLowerCase().includes(sq) const isVisible = (n: typeof nodes[0]) => { if (!matchesSearch(n)) return false if (filter === "all") return true if (filter === "online" || filter === "degraded" || filter === "offline") return n.status === filter return n.type === filter } // ── Side panel ──────────────────────────────────────────────────────────── function selectServer(s: Server) { setSelected(prev => prev?.id === s.id ? null : s) setSelWanIdx(null) setHoveredId(null) } function selectWan(s: Server, wanIdx: number) { setSelected(s) setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx) } const connectedTunnels = selected && selected.type !== "home-router" ? greTunnels.filter(t => t.serverId === selected.id || nodes.find(n => n.id === selected.id)?.host === t.remoteAddress ) : [] const hoveredNode = hoveredId ? nodes.find(n => n.id === hoveredId) : null const filterBtns: { value: FilterKey; label: string }[] = [ { value: "all", label: "Все" }, { value: "online", label: "Онлайн" }, { value: "degraded", label: "Внимание" }, { value: "offline", label: "Оффлайн" }, { value: "jump-host", label: "JumpHost" }, { value: "exit-node", label: "Exit Node" }, { value: "home-router", label: "Home Router"}, ] // ═════════════════════════════════════════════════════════════════════════ return (
Экспорт SVG } /> {/* ── Toolbar ── */}
{/* Filter pills */}
{filterBtns.map(b => ( ))}
{/* Search */}
setSearch(e.target.value)} placeholder="Поиск узла…" className="h-7 pl-6 pr-3 text-xs rounded-md border border-border bg-muted/40 placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring w-44" /> {search && ( )}
{/* Layers dropdown */}
{showLayers && (
setShowLayers(false)}> {([ { key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" }, { key: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" }, { key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" }, { key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" }, ] as const).map(item => ( ))} {(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0) && (
)}
)}
{/* Stats */}
{servers.filter(s => s.status === "online").length} онлайн {servers.filter(s => s.status === "degraded").length} с проблемами {servers.filter(s => s.status === "offline").length} оффлайн {greTunnels.length} GRE
{/* ── Main area ── */}
{/* ── Canvas ── */}
{ e.preventDefault(); onSvgMouseDown(e) }} onMouseMove={onSvgMouseMove} onMouseUp={onSvgMouseUp} onMouseLeave={() => { finishNodeDrag() dragRef.current = null; setIsDragging(false); setHoveredId(null) }} > {WAN_COLORS.map((_, i) => ( ))} {/* Background grid */} {/* ── GRE edges ── */} {greEdges.map((e, i) => { const ts = TUNNEL_STYLE[e.tunnel.status] const dimmed = filter !== "all" && !isVisible(e.from) && !isVisible(e.to) const probe = TUNNEL_PROBES[e.tunnel.id] const pc = pingColor(probe?.pingMs ?? null) const mx = (e.from.x + e.to.x) / 2 const my = (e.from.y + e.to.y) / 2 return ( {showAnimDots && ts.dotOpacity > 0 && ( )} {showPingBadges && probe && ( )} ) })} {/* ── Home Router → WAN satellite connectors ── */} {servers.filter(s => s.type === "home-router").flatMap(router => { const rNode = nodes.find(n => n.id === router.id) if (!rNode) return [] return (router.wanUplinks ?? []).map((wan, wIdx) => { const sat = effectiveSatPos[router.id]?.[wIdx] const color = WAN_COLORS[wIdx] ?? "#888" const vis = filter === "all" || filter === "home-router" || filter === "online" if (!sat) return null return ( ) }).filter(Boolean) })} {/* ── WAN→JH edges ── */} {WAN_JH_EDGES.map((edge, i) => { const jh = nodeById[edge.jhId] const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx] if (!jh || !satPos) return null const color = WAN_COLORS[edge.wanIdx] ?? "#888" const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online" const mx = (satPos.x + jh.x) / 2 const my = (satPos.y + jh.y) / 2 const isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx) return ( {showAnimDots && edge.active && ( )} {showPingBadges && ( )} ) })} {/* ── Server nodes ── */} {nodes.map(n => ( { if (suppressClickRef.current) { suppressClickRef.current = false; return } selectServer(n) }} onDblClick={() => { selectServer(n); focusNode(n.x, n.y) }} onHoverChange={h => setHoveredId(h ? n.id : null)} onContextMenu={e => setCtxMenu({ x: e.clientX, y: e.clientY, server: n })} onMouseDown={e => onNodeMouseDown(e, n)} /> ))} {/* ── WAN satellite nodes ── */} {servers.filter(s => s.type === "home-router").flatMap(router => { const vis = filter === "all" || filter === "home-router" || filter === "online" return (router.wanUplinks ?? []).map((wan, wIdx) => { const sat = effectiveSatPos[router.id]?.[wIdx] if (!sat) return null const color = WAN_COLORS[wIdx] ?? "#888" const isActive = WAN_JH_EDGES.filter(e => e.homeId === router.id && e.wanIdx === wIdx).some(e => e.active) const isSatSel = selected?.id === router.id && selWanIdx === wIdx const satKey = `${router.id}-${wIdx}` return ( { if (suppressClickRef.current) { suppressClickRef.current = false; return } selectWan(router, wIdx) }} onMouseDown={e => onSatMouseDown(e, router.id, wIdx, sat.x, sat.y)} /> ) }).filter(Boolean) })} {/* ── Hover tooltip ── */} {hoveredNode && !isDragging && ( )} {/* ── Legend (viewport-fixed) ── */} ЛЕГЕНДА {(["online","degraded","offline"] as const).map((s, i) => ( {s === "online" ? "Онлайн" : s === "degraded" ? "Внимание" : "Оффлайн"} ))} {[ { fill: "#7c3aed", label: "JH", text: "JumpHost" }, { fill: "#0369a1", label: "EN", text: "Exit Node" }, { fill: "#16a34a", label: "HR", text: "Home Router" }, ].map((t, i) => ( {t.label} {t.text} ))} WAN АПЛИНКИ {WAN_COLORS.slice(0, 2).map((c, i) => ( WAN{i + 1} {i === 0 ? "primary" : "backup"} ))} {/* ── Keyboard hints (viewport-fixed) ── */} {showHints && ( {[ ["Scroll", "zoom"], ["+/-", "zoom in/out"], ["F / 0", "вписать"], ["M", "minimap"], ["P", "ping"], ["Esc", "снять выбор"], ["2× клик", "навигация"], ].reverse().map(([k, l], i) => ( {k} {l} ))} )} {/* ── Zoom controls ── */} applyZoomCenter(1.3)} onZoomOut={() => applyZoomCenter(1 / 1.3)} onFit={fitView} /> {/* ── Minimap ── */} {showMinimap && ( setShowMinimap(false)} onPan={(x, y) => setPan({ x, y })} /> )} {/* ── "Show minimap" button when hidden ── */} {!showMinimap && ( )} {/* ── Search result count ── */} {sq && (
{nodes.filter(isVisible).length === 0 ? "Ничего не найдено" : `Найдено: ${nodes.filter(isVisible).length} узл${nodes.filter(isVisible).length === 1 ? "" : nodes.filter(isVisible).length < 5 ? "а" : "ов"}`}
)}
{/* ── Side panel ── */} {selected && (

{selected.name}

{selected.host}

{/* Badges */}
{TYPE_STYLE[selected.type].label} · {TYPE_LABELS[selected.type]}
{/* Info rows */}
{[ ["Площадка", selected.site], ["Модель", selected.model], ["RouterOS", selected.os], ...(selected.type !== "home-router" ? [["ASN", selected.asn], ["Сессий", String(selected.sessions)]] : [["LAN", selected.lanSubnet ?? "—"]]), ].map(([k, v]) => (
{k} {v}
))}
Задержка {selected.latency == null ? "— офлайн" : `${selected.latency} мс`}
{/* WAN uplinks */} {selected.type === "home-router" && selected.wanUplinks && (

WAN-аплинки ({selected.wanUplinks.length})

{selected.wanUplinks.map((wan, wIdx) => { const color = WAN_COLORS[wIdx] ?? "#888" const myEdges = WAN_JH_EDGES.filter(e => e.homeId === selected.id && e.wanIdx === wIdx) const isActive = myEdges.some(e => e.active) const isSel = selWanIdx === wIdx return (
setSelWanIdx(prev => prev === wIdx ? null : wIdx)} className={cn( "rounded-lg border p-3 cursor-pointer transition-all", isSel ? "bg-muted/60" : "bg-muted/20 hover:bg-muted/40", )} style={{ borderColor: isSel ? color : undefined }}>
{wan.name} {isActive ? "primary" : "backup"}
{[["ISP", wan.isp], ["Iface", wan.iface], ["IP", wan.ip], ["BW", `↓${wan.maxDl} ↑${wan.maxUl} Мбит`]].map(([k, v]) => (
{k} {v}
))}
{myEdges.length > 0 && (

Подключения к JH

{myEdges.map((e, ei) => { const jh = servers.find(s => s.id === e.jhId) return (
→ {jh?.name ?? e.jhId} {e.pingMs} мс
) })}
)}
) })}
)} {/* GRE tunnels */} {connectedTunnels.length > 0 && (

GRE-туннели ({connectedTunnels.length})

{connectedTunnels.map(t => { const ts = TUNNEL_STYLE[t.status] const peer = servers.find(s => s.host === t.remoteAddress && s.id !== selected.id) || servers.find(s => s.id === t.serverId && s.id !== selected.id) const probe = TUNNEL_PROBES[t.id] const pc = pingColor(probe?.pingMs ?? null) return (
{t.name} {t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
→ {peer?.site ?? t.remoteAddress} {t.ipsec ? IPsec : Plain}
{probe?.pingMs != null && (
ping / speed {probe.pingMs} мс {probe.dlMbps != null && ( ↓{probe.dlMbps} ↑{probe.ulMbps} )}
)}
) })}
)} {/* Resources */} {(() => { const res = SERVER_RESOURCES[selected.id] if (!res) return null const cpuPct = res.cpu const ramPct = Math.round(res.ramUsed / res.ramTotal * 100) const hddPct = Math.round(res.hddUsed / res.hddTotal * 100) return (

Ресурсы

{/* CPU with sparkline */}
CPU {cpuPct}%
{/* RAM */}
RAM {fmtMB(res.ramUsed)} / {fmtMB(res.ramTotal)}
{/* HDD */}
HDD {fmtMB(res.hddUsed)} / {fmtMB(res.hddTotal)}
{/* Misc metrics */}
Uptime {fmtUptime(res.uptimeSeconds)}
{res.temp != null && (
Температура = 70 ? "text-red-400" : res.temp >= 55 ? "text-amber-400" : "text-emerald-400", )}> {res.temp}°C
)}
Плата {BOARD_MAP[selected.type]}
) })()} {/* Navigate hint */}
)}
{/* ── Context menu ── */} {ctxMenu && setCtxMenu(null)} />}
) }