"use client" import { useState, useRef, useEffect, useMemo, useCallback } from "react" import { PageHeader } from "@/components/page-header" import { servers as mockServers, greTunnels as mockGreTunnels, type GreTunnel, type Server, type ServerStatus, type ServerType, } from "@/lib/data" import { useDataSource } from "@/lib/data-source" import { requestJson } from "@/shared/api/http-client" import { buildGreMapEdges, buildServerResourceMap, buildWanJhEdges, computeNetworkMapLayout, NETWORK_MAP_LAYOUT_REVISION, NETWORK_MAP_PIPELINE_Y, findServerByGreRemote, greSourceWanIndexOnMap, greTunnelProbe, type GreMapEdge, type WanJhEdge, } from "@/lib/network-map-layout" import { collectGreEndpointHostnames, greResolvedMapFromApi, isIpv4String, normalizeGreEndpointAddr, } from "@/lib/gre-endpoint-resolve" import { assignSpeedProbesToGreTunnels, assignSpeedProbesToWanJhEdges, canonicalGreServerPairKey, greOuterSummaryLine, ifaceNameForGreOuterIp, mergeGreMetricsWithSpeedProbe, wanJhEdgeMapKey, type GreSpeedProbeSnapshot, } from "@/lib/map-gre-speed-probe" import { Button } from "@/components/ui/button" import { StatusBadge } from "@/components/status-badge" import { StatusDot } from "@/components/status-dot" import { DownloadIcon, XIcon, LockIcon, LockOpenIcon, WifiIcon, RefreshCwIcon, ZoomInIcon, ZoomOutIcon, Maximize2Icon, SearchIcon, LayersIcon, MapIcon, HomeIcon, TerminalIcon, ShieldIcon, CableIcon, CopyIcon, ActivityIcon, ExternalLinkIcon, } from "lucide-react" import { cn } from "@/lib/utils" import Link from "next/link" import { Flag } from "@/components/flag" // ─── Resource metrics (для мини-блока справа; числа детерминированы по id узла) ─ // ─── Backend → frontend (как /servers) ─────────────────────────────────────── interface BackendServerRow { id: number name: string host: string port: number useSsl: boolean verifySsl: boolean username: string password: string type: ServerType site: string country: string asn: string comment: string enabled: boolean lanSubnet?: string wanUplinks?: Array<{ id: string name: string isp: string iface: string ip: string maxDl: number maxUl: number }> status: "online" | "offline" | null latency: number | null os: string | null model: string | null sessions: number /** Снапшот последнего poll (как в разделе «Серверы») */ uptime?: string | null cpuLoad?: number | null freeMemory?: number | null totalMemory?: number | null polledAt?: string | null createdAt: string updatedAt: string } function mapBackendRowToServer(s: BackendServerRow): Server { return { id: String(s.id), name: s.name || s.host, host: s.host, type: s.type, site: s.site, country: s.country, asn: s.asn, model: s.model ?? "—", os: s.os ?? "—", enabled: s.enabled, status: (s.status === null ? "offline" : s.status) as Server["status"], latency: s.latency != null ? Math.round(s.latency) : null, sessions: s.sessions ?? 0, comment: s.comment || undefined, lanSubnet: s.lanSubnet || undefined, wanUplinks: Array.isArray(s.wanUplinks) && s.wanUplinks.length ? s.wanUplinks : undefined, uptime: s.uptime ?? undefined, cpuLoad: s.cpuLoad ?? undefined, freeMemory: s.freeMemory ?? undefined, totalMemory: s.totalMemory ?? undefined, polledAt: s.polledAt ?? undefined, } } interface ApiGreTunnelRow { id: string name: string serverId: string localAddress: string remoteAddress: string localInnerIp: string remoteInnerIp: string poolId: string ipsec: null mtu: number keepaliveInterval: number keepaliveRetries: number dscp: "inherit" | number clampTcpMss: boolean allowFastPath: boolean comment: string enabled: boolean status: "up" | "down" | "degraded" } function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel { return { id: t.id, name: t.name, serverId: String(t.serverId), localAddress: t.localAddress, remoteAddress: t.remoteAddress, localInnerIp: t.localInnerIp, remoteInnerIp: t.remoteInnerIp, poolId: t.poolId || "live", ipsec: null, mtu: t.mtu, keepaliveInterval: t.keepaliveInterval, keepaliveRetries: t.keepaliveRetries, dscp: t.dscp, clampTcpMss: t.clampTcpMss, allowFastPath: t.allowFastPath, comment: t.comment, enabled: t.enabled, status: t.status, } } function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { return requestJson(backendUrl, path, init) } } 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 = { // HR — визуальный корень топологии: крупнее и с более сильным акцентом. "home-router": { label: "HR", fill: "#16a34a", r: 40 }, // JH — агрегирующий слой по центру. "jump-host": { label: "JH", fill: "#7c3aed", r: 34 }, // EN — крайние edge-узлы, менее акцентные. "exit-node": { label: "EN", fill: "#0369a1", 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 }, } 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("-") } function greStatusToServerStatus(s: "up" | "down" | "degraded"): ServerStatus { if (s === "up") return "online" if (s === "degraded") return "degraded" return "offline" } /** * Позиция бейджа на отрезке: не только середина, а доля t + сдвиг по нормали. * GRE и PingBadge (WAN→JH) используют разные t и знак нормали — меньше наложений. */ function edgeBadgePosition( x1: number, y1: number, x2: number, y2: number, t: number, normalPx: number, ): { mx: number; my: number } { const tc = Math.min(0.82, Math.max(0.18, t)) const px = x1 + (x2 - x1) * tc const py = y1 + (y2 - y1) * tc const dx = x2 - x1 const dy = y2 - y1 const len = Math.hypot(dx, dy) || 1 const nx = -dy / len const ny = dx / len return { mx: px + nx * normalPx, my: py + ny * normalPx } } // ─── Filter ─────────────────────────────────────────────────────────────────── type FilterKey = "all" | "online" | "degraded" | "offline" | "jump-host" | "exit-node" | "home-router" function greEdgeKey(e: GreMapEdge): string { const la = (e.tunnel.localAddress ?? "").trim() const ra = (e.tunnel.remoteAddress ?? "").trim() return `${e.fromServer.id}|${e.toServer.id}|${e.tunnel.id}|${la}|${ra}` } // ─── SVG sub-components ─────────────────────────────────────────────────────── /** Бейдж на ребре WAN → JumpHost (не GRE): сумма latency домашнего узла и JH — те же поля, что в разделе Серверы. */ 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 ( WAN → JumpHost: latency (из каталога) дом + latency JH, без отдельного ping по ребру. Не RTT по GRE. {ping == null ? "—" : `${ping} мс`} {hasSpeed && ( {`↓${dl} ↑${ul}`} )} ) } /** Метрики на GRE-ребре: компактно; подробности — в tooltip (`title`). */ function GreEdgeMetricBadge({ mx, my, pingMs, dl, ul, onOpen, rttFromMonitor, throughputFromMonitor, outerSummary, }: { mx: number my: number pingMs: number | null dl: number | null ul: number | null onOpen: (e: React.MouseEvent) => void /** Данные из Мониторинг → скорость (uptime speed-probes) */ rttFromMonitor?: boolean throughputFromMonitor?: boolean /** Внешние IP GRE и WAN — только в подсказке при наведении */ outerSummary?: string }) { const hasSpeed = dl != null && ul != null && (throughputFromMonitor || dl > 0 || ul > 0) const bw = 62 const bh = 24 + (hasSpeed ? 14 : 0) const v1 = pingMs == null ? "—" : `${pingMs} мс` const hint = (outerSummary ? `${outerSummary}. ` : "") + (rttFromMonitor ? "RTT — ping/BT из «Мониторинг → скорость». " : "RTT — модель по данным туннеля. ") + (throughputFromMonitor ? "Строка ниже — TX/RX с пробы. " : "") + "Клик — панель справа." return ( { e.stopPropagation() }} onClick={(e) => { e.stopPropagation(); onOpen(e) }} > {hint} {v1} {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, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onMouseDown }: { n: Server & { x: number; y: number } isSel: boolean; isVis: boolean; isDragged: boolean /** Не дублировать «N мс» из каталога, если на карте уже показаны метрики GRE (оранжевые числа в круге). */ hideCatalogLatency?: 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] 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} {n.country?.trim() ? ( {/* Внутри foreignObject Tailwind не всегда применяется к тексту — явные стили, без lineHeight:0 (иначе подпись пропадает). */}
)} > 32 ? 18 : 16} /> 32 ? 13 : 11, color: "#f1f5f9", lineHeight: 1.25, }} > {n.site}
) : ( 32 ? "13" : "11"} fontWeight="700" fill="#f1f5f9" fontFamily="ui-monospace,monospace">{n.site} )} {!hideCatalogLatency && ( {n.latency == null ? "офлайн" : `${n.latency} мс`} )} {n.type === "home-router" && ( {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, wanJhEdges, homeRouters, onClose, onPan }: { pan: { x: number; y: number }; zoom: number nodes: (Server & { x: number; y: number })[] greEdges: GreMapEdge[] satPos: Record wanJhEdges: WanJhEdge[] homeRouters: Server[] 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 */} {wanJhEdges.map((edge) => { const sat = satPos[edge.homeId]?.[edge.wanIdx] const jh = nodes.find(n => n.id === edge.jhId) if (!sat || !jh) return null const edgeKey = `${edge.homeId}-${edge.wanIdx}-${edge.jhId}` return })} {/* nodes */} {nodes.map(n => ( ))} {/* WAN satellites */} {homeRouters.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() { const { mode, backendUrl } = useDataSource() const useLiveData = mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const [mapServers, setMapServers] = useState([]) const [mapGreTunnels, setMapGreTunnels] = useState([]) const [speedProbes, setSpeedProbes] = useState([]) /** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */ const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState>({}) const [dataError, setDataError] = useState(null) const [dataLoading, setDataLoading] = useState(false) const greResolvedMap = useMemo( () => new Map(Object.entries(greResolvedIpv4ByHost)) as ReadonlyMap, [greResolvedIpv4ByHost], ) const loadLive = useCallback(async () => { if (!useLiveData) return setDataLoading(true) setDataError(null) try { const [rows, fr, spRes] = await Promise.all([ apiFetch("/api/servers"), apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels").catch(() => ({ tunnels: [] })), apiFetch<{ probes?: Array<{ id: string srcServerId: string dstServerId: string enabled?: boolean srcInterface?: string dstInterface?: string lastTxAvgMbps?: number | null lastRxAvgMbps?: number | null lastPingRttMs?: number | null }> }>("/api/uptime/speed-probes").catch(() => ({ probes: [] })), ]) setMapServers(rows.map(mapBackendRowToServer)) const mappedTunnels = (fr.tunnels ?? []).map(apiGreToGreTunnel) setMapGreTunnels(mappedTunnels) setSpeedProbes( (spRes.probes ?? []).map((p) => ({ id: p.id, srcServerId: String(p.srcServerId), dstServerId: String(p.dstServerId), enabled: p.enabled !== false, srcInterface: p.srcInterface || undefined, dstInterface: p.dstInterface || undefined, lastTxAvgMbps: p.lastTxAvgMbps, lastRxAvgMbps: p.lastRxAvgMbps, lastPingRttMs: p.lastPingRttMs, })), ) const hostSet = new Set(collectGreEndpointHostnames(mappedTunnels)) for (const row of rows) { if (!Array.isArray(row.wanUplinks)) continue for (const w of row.wanUplinks) { const raw = (w.ip ?? "").trim() if (!raw) continue const n = normalizeGreEndpointAddr(raw) if (!n || n === "0.0.0.0") continue if (!isIpv4String(n)) hostSet.add(n.toLowerCase()) } } const hostnames = [...hostSet] if (hostnames.length > 0) { try { const res = await apiFetch<{ results: Record }>( "/api/network/resolve-hosts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ hosts: hostnames }), }, ) setGreResolvedIpv4ByHost(Object.fromEntries(greResolvedMapFromApi(res.results))) } catch { setGreResolvedIpv4ByHost({}) } } else { setGreResolvedIpv4ByHost({}) } } catch (e) { setDataError(e instanceof Error ? e.message : "Ошибка загрузки") } finally { setDataLoading(false) } }, [apiFetch, useLiveData]) useEffect(() => { if (!useLiveData) { queueMicrotask(() => { setMapServers(mockServers) setMapGreTunnels(mockGreTunnels) setSpeedProbes([]) setGreResolvedIpv4ByHost({}) setDataError(null) }) return } queueMicrotask(() => { void loadLive() }) }, [useLiveData, loadLive]) const autoLayout = useMemo( () => computeNetworkMapLayout(mapServers, mapGreTunnels, greResolvedMap), [mapServers, mapGreTunnels, greResolvedMap], ) const wanJhEdges = useMemo(() => buildWanJhEdges(mapServers), [mapServers]) const srvResMap = useMemo(() => buildServerResourceMap(mapServers), [mapServers]) const homeRouters = useMemo( () => mapServers.filter((s) => s.type === "home-router"), [mapServers], ) // ── 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>({}) /** После обновления алгоритма раскладки (см. NETWORK_MAP_LAYOUT_REVISION) сбрасываем drag, иначе старые координаты «перебивают» computeNetworkMapLayout. */ useEffect(() => { if (typeof window === "undefined") return try { const k = "mm-network-map-layout-rev" if (sessionStorage.getItem(k) !== String(NETWORK_MAP_LAYOUT_REVISION)) { // Важно: без явного сброса старые drag-координаты остаются в state // и перекрывают новый авто-лейаут на live-данных. setNodePositions({}) setSatPositions({}) sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION)) } } catch { /* private mode / disabled storage */ } }, []) // ── Context menu ──────────────────────────────────────────────────────────── const [ctxMenu, setCtxMenu] = useState(null) const [selectedGreEdge, setSelectedGreEdge] = 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) const effectiveSatPos = useMemo(() => { const out: Record = {} mapServers .filter((s) => s.type === "home-router") .forEach((r) => { const defaults = autoLayout.wanSatPos[r.id] ?? [] const user = satPositions[r.id] if (user && user.length === defaults.length) { out[r.id] = defaults.map((def, i) => user[i] ?? def) } else { out[r.id] = defaults } }) return out }, [mapServers, autoLayout.wanSatPos, satPositions]) const nodePosById = useMemo( () => Object.fromEntries( mapServers.map((s) => { const pos = nodePositions[s.id] ?? autoLayout.nodePos[s.id] ?? { x: W / 2, y: NETWORK_MAP_PIPELINE_Y } return [s.id, pos] as const }), ), [mapServers, nodePositions, autoLayout.nodePos], ) const greEdges = useMemo( () => buildGreMapEdges(mapGreTunnels, mapServers, nodePosById, effectiveSatPos, greResolvedMap), [mapGreTunnels, mapServers, nodePosById, effectiveSatPos, greResolvedMap], ) /** Speed-пробы на сегментах WAN→JH (тот же формат бейджа, что на GRE JH↔EN). */ const speedProbeByWanJhKey = useMemo( () => assignSpeedProbesToWanJhEdges(wanJhEdges, mapServers, speedProbes), [wanJhEdges, mapServers, speedProbes], ) /** * WAN→JH: не дублировать линию спутник→пир, если уже есть GRE с того же WAN к тому же узлу * (JH или exit — иначе «второй» бейдж speed посередине сегмента). */ const suppressWanJhPingBadge = useMemo(() => { const s = new Set() for (const g of greEdges) { if (g.fromServer.type !== "home-router") continue if (g.toServer.type === "home-router") continue const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap) if (widx == null) continue s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`) } return s }, [greEdges, greResolvedMap]) /** Концы GRE: не дублировать «N мс» из каталога внутри круга, если показаны бейджи по туннелям. */ const hideCatalogLatencyByNodeId = useMemo(() => { if (!showPingBadges) return new Set() const s = new Set() for (const e of greEdges) { s.add(e.fromServer.id) s.add(e.toServer.id) } return s }, [greEdges, showPingBadges]) /** Одна speed-проба не может быть назначена двум GRE между одной парой узлов (RT vs MTS и т.д.). */ const speedProbeByTunnelId = useMemo( () => assignSpeedProbesToGreTunnels( greEdges.map((e) => ({ tunnel: { ...e.tunnel, id: greEdgeKey(e) }, fromServer: e.fromServer, toServer: e.toServer, })), speedProbes, greResolvedMap, ), [greEdges, speedProbes, greResolvedMap], ) /** WAN→JH: не показывать GreEdgeMetricBadge со speed, если те же метрики уже на GRE (один бейдж на сегмент). */ const suppressWanJhSpeedBadgeDuplicate = useMemo(() => { const s = new Set() for (const g of greEdges) { if (g.fromServer.type !== "home-router") continue if (g.toServer.type === "home-router") continue const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap) if (widx == null) continue const spGre = speedProbeByTunnelId.get(greEdgeKey(g)) const merged = mergeGreMetricsWithSpeedProbe(spGre, greTunnelProbe(g.tunnel)) if (!merged.hasSpeedMonitor) continue s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`) } return s }, [greEdges, speedProbeByTunnelId, greResolvedMap]) /** WAN→JH: скрываем саму линию, если тот же отрезок уже отрисован GRE (одна связь на карте). */ const suppressWanJhLineDuplicate = useMemo(() => { const s = new Set() for (const g of greEdges) { if (g.fromServer.type !== "home-router") continue if (g.toServer.type === "home-router") continue const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap) if (widx == null) continue s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`) } return s }, [greEdges, greResolvedMap]) const visibleWanJhEdges = useMemo( () => wanJhEdges.filter((e) => !suppressWanJhLineDuplicate.has(wanJhEdgeMapKey(e))), [wanJhEdges, suppressWanJhLineDuplicate], ) /** Один бейдж на совпадающий отрезок карты (два туннеля в БД HR→MSK и MSK→HR — одна линия). */ const greTunnelIdsWithMapBadge = useMemo(() => { const edgeByTunnel = new Map(greEdges.map((e) => [greEdgeKey(e), e] as const)) const geomKey = (e: GreMapEdge) => { const a = `${Math.round(e.from.x)},${Math.round(e.from.y)}` const b = `${Math.round(e.to.x)},${Math.round(e.to.y)}` return a <= b ? `${a}|${b}` : `${b}|${a}` } const rank = (tid: string): number => { const e = edgeByTunnel.get(tid) if (!e) return -1 const sp = speedProbeByTunnelId.get(tid) const m = mergeGreMetricsWithSpeedProbe(sp, greTunnelProbe(e.tunnel)) let r = m.hasSpeedMonitor ? 1000 : 0 r += m.pingMs ?? 0 return r } const winners = new Map() for (const e of greEdges) { const key = geomKey(e) const cur = winners.get(key) const edgeId = greEdgeKey(e) if (!cur || rank(edgeId) > rank(cur) || (rank(edgeId) === rank(cur) && edgeId.localeCompare(cur) < 0)) { winners.set(key, edgeId) } } return new Set(winners.values()) }, [greEdges, speedProbeByTunnelId]) /** Несколько GRE между одной парой узлов — смещаем бейджи по дуге и по нормали, без наложения. */ const greBadgeStaggerByTunnelId = useMemo(() => { const pairOrder = new Map() const out = new Map() for (const ge of greEdges) { const k = canonicalGreServerPairKey(ge.fromServer, ge.toServer) const lane = pairOrder.get(k) ?? 0 out.set(greEdgeKey(ge), lane) pairOrder.set(k, lane + 1) } return out }, [greEdges]) const nodes = mapServers .map((s) => ({ ...s, ...nodePosById[s.id]! })) // Визуальный приоритет: HR поверх JH, JH поверх EN. .sort((a, b) => { const weight = (t: ServerType) => t === "home-router" ? 3 : t === "jump-host" ? 2 : 1 return weight(a.type) - weight(b.type) }) const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n])) // ── 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); setSelectedGreEdge(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]) // eslint-disable-line react-hooks/exhaustive-deps -- applyZoomCenter/fitView ниже; handler обновляется через [zoom, pan] // ── 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] ?? (autoLayout.wanSatPos[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); setSelectedGreEdge(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}`) } // ── 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) { setSelectedGreEdge(null) setSelected(prev => prev?.id === s.id ? null : s) setSelWanIdx(null) setHoveredId(null) } function selectWan(s: Server, wanIdx: number) { setSelectedGreEdge(null) setSelected(s) setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx) } function openWanJhSpeedDetail(ev: React.MouseEvent, edge: WanJhEdge) { ev.stopPropagation() const home = mapServers.find((s) => s.id === edge.homeId) if (!home) return setSelectedGreEdge(null) setSelected(home) setSelWanIdx(edge.wanIdx) setHoveredId(null) } const connectedTunnels = selected ? mapGreTunnels.filter((t) => { if (t.serverId === selected.id) return true const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap) if (peer?.id === selected.id) return true const selectedHost = normalizeGreEndpointAddr(selected.host) const selectedWanIps = (selected.wanUplinks ?? []) .map((w) => normalizeGreEndpointAddr(w.ip)) .filter(Boolean) const remote = normalizeGreEndpointAddr(t.remoteAddress) const local = normalizeGreEndpointAddr(t.localAddress) return ( (selectedHost.length > 0 && (remote === selectedHost || local === selectedHost)) || selectedWanIps.includes(remote) || selectedWanIps.includes(local) ) }) : [] const grePrimaryByGroupKey = useMemo(() => { if (!selected) return new Map() const group = new Map() const score = (t: GreTunnel): number => { const statusScore = t.status === "up" ? 3 : t.status === "degraded" ? 2 : 1 const enabledScore = t.enabled ? 10 : 0 return enabledScore + statusScore } for (const t of connectedTunnels) { const peer = t.serverId === selected.id ? findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap) : mapServers.find((s) => s.id === t.serverId) const groupKey = peer?.id ?? (normalizeGreEndpointAddr(t.remoteAddress) || t.remoteAddress) const arr = group.get(groupKey) ?? [] arr.push(t) group.set(groupKey, arr) } const primaryByKey = new Map() for (const [groupKey, tunnels] of group.entries()) { const sorted = [...tunnels].sort((a, b) => { const d = score(b) - score(a) if (d !== 0) return d return a.id.localeCompare(b.id) }) const first = sorted[0] if (first) primaryByKey.set(groupKey, first.id) } return primaryByKey }, [connectedTunnels, selected, mapServers, greResolvedMap]) 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 (
{useLiveData && ( )} } /> {/* ── 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 */}
{useLiveData ? "Живые данные · API" : "Демо · mock"} {dataError && ( {dataError} )} {mapServers.filter(s => s.status === "online").length} онлайн {mapServers.filter(s => s.status === "degraded").length} с проблемами {mapServers.filter(s => s.status === "offline").length} оффлайн {greEdges.length} / {mapGreTunnels.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 edgeId = greEdgeKey(e) const ts = TUNNEL_STYLE[e.tunnel.status] const fromN = nodeById[e.fromServer.id] const toN = nodeById[e.toServer.id] const dimmed = filter !== "all" && fromN && toN && !isVisible(fromN) && !isVisible(toN) const baseProbe = greTunnelProbe(e.tunnel) const spGre = speedProbeByTunnelId.get(edgeId) const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe) const rttFromMonitor = spGre?.lastPingRttMs != null const throughputFromMonitor = spGre?.lastTxAvgMbps != null || spGre?.lastRxAvgMbps != null const badgeLane = greBadgeStaggerByTunnelId.get(edgeId) ?? 0 const tBadge = Math.min(0.78, Math.max(0.22, 0.34 + badgeLane * 0.052)) const normalPx = (badgeLane % 2 === 0 ? 1 : -1) * (16 + badgeLane * 22) const { mx, my } = edgeBadgePosition( e.from.x, e.from.y, e.to.x, e.to.y, tBadge, normalPx, ) function openGreDetail(ev: React.MouseEvent) { ev.stopPropagation() setSelectedGreEdge(e) setSelected(null) setSelWanIdx(null) } return ( { ev.stopPropagation() }} onClick={openGreDetail} > GRE: подробнее {showAnimDots && ts.dotOpacity > 0 && ( )} {showPingBadges && greTunnelIdsWithMapBadge.has(edgeId) && ( )} ) })} {/* ── Home Router → WAN satellite connectors ── */} {homeRouters.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 ── */} {visibleWanJhEdges.map((edge) => { const jh = nodeById[edge.jhId] const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx] if (!jh || !satPos) return null const edgeKey = `${edge.homeId}-${edge.wanIdx}-${edge.jhId}` const color = WAN_COLORS[edge.wanIdx] ?? "#888" const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online" const { mx, my } = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.62, -17) const isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx) return ( {showAnimDots && edge.active && ( )} {showPingBadges && (() => { const kj = wanJhEdgeMapKey(edge) const sp = speedProbeByWanJhKey.get(kj) const merged = mergeGreMetricsWithSpeedProbe(sp, { pingMs: edge.pingMs, dlMbps: null, ulMbps: null, }) const hasMonitor = merged.hasSpeedMonitor if (!hasMonitor && suppressWanJhPingBadge.has(kj)) return null if (hasMonitor && suppressWanJhSpeedBadgeDuplicate.has(kj)) return null if (hasMonitor) { return ( openWanJhSpeedDetail(ev, edge)} rttFromMonitor={sp?.lastPingRttMs != null} throughputFromMonitor={ sp?.lastTxAvgMbps != null || sp?.lastRxAvgMbps != null } /> ) } return })()} ) })} {/* ── 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 ── */} {homeRouters.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 = wanJhEdges.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 (узел или выбранное GRE-ребро) ── */} {(selectedGreEdge || selected) && (
{selectedGreEdge ? ( <>

{selectedGreEdge.tunnel.name}

GRE · {selectedGreEdge.fromServer.name} ({TYPE_LABELS[selectedGreEdge.fromServer.type]}) ↔{" "} {selectedGreEdge.toServer.name} ({TYPE_LABELS[selectedGreEdge.toServer.type]})

{(() => { const edge = selectedGreEdge const t = edge.tunnel const selectedEdgeId = greEdgeKey(edge) const localWanName = ifaceNameForGreOuterIp(edge.fromServer, t.localAddress, greResolvedMap) const remoteWanName = ifaceNameForGreOuterIp(edge.toServer, t.remoteAddress, greResolvedMap) const baseProbe = greTunnelProbe(t) const spGr = speedProbeByTunnelId.get(selectedEdgeId) const merged = mergeGreMetricsWithSpeedProbe(spGr, baseProbe) const rttMon = spGr?.lastPingRttMs != null const bwMon = spGr?.lastTxAvgMbps != null || spGr?.lastRxAvgMbps != null const ts = TUNNEL_STYLE[t.status] const tunnelStatusLabel = t.status === "up" ? "Работает" : t.status === "degraded" ? "Деградация" : "Недоступен" const tunnelStatusBadge = t.status === "up" ? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)]" : t.status === "degraded" ? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)]" : "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)]" return ( <>
{tunnelStatusLabel} GRE-туннель

Метрики на карте

{rttMon ? "Мониторинг (ping/BT)" : "Модель RTT"} {merged.pingMs == null ? "—" : `${merged.pingMs} мс`}
{bwMon ? "TX / RX (BT)" : "Скорость (модель)"} {merged.dlMbps != null && merged.ulMbps != null ? `↓${merged.dlMbps} ↑${merged.ulMbps}` : "—"}

{merged.hasSpeedMonitor ? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам." : "«Модель RTT» и «скорость» — демо до появления подходящей speed-пробы в «Мониторинг → скорость»."}

Статус туннеля {t.status}
На узле {edge.fromServer.name}
Пир (каталог) {edge.toServer.name}
Включён {t.enabled ? "да" : "нет"}
IPsec {t.ipsec ? ( <> да ) : ( <> нет )}

Адреса и параметры

Local outer {t.localAddress || "—"}
{localWanName && (
WAN на узле {localWanName}
)}
Remote outer {t.remoteAddress || "—"}
{remoteWanName && (
WAN у пира {remoteWanName}
)}
Inner {t.localInnerIp} ↔ {t.remoteInnerIp}
MTU / Keepalive {t.mtu} /{" "} {t.keepaliveInterval === 0 ? "выкл." : `${t.keepaliveInterval}s×${t.keepaliveRetries}`}
Открыть раздел GRE-туннелей ) })()}
) : 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 = wanJhEdges.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) => { const jh = mapServers.find(s => s.id === e.jhId) const edgeKey = `${e.homeId}-${e.wanIdx}-${e.jhId}` return (
→ {jh?.name ?? e.jhId} {e.pingMs == null ? "—" : `${e.pingMs} мс`}
) })}
)}
) })}
)} {/* GRE tunnels */} {connectedTunnels.length > 0 && (

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

{connectedTunnels.map(t => { const ts = TUNNEL_STYLE[t.status] const peer: Server | undefined = t.serverId === selected.id ? findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap) : mapServers.find((s) => s.id === t.serverId) const fromServer = mapServers.find((s) => s.id === t.serverId) const toServer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap) const tunnelPanelKey = fromServer && toServer ? `${fromServer.id}|${toServer.id}|${t.id}|${t.localAddress}|${t.remoteAddress}` : `${t.serverId}|${t.id}|${t.localAddress}|${t.remoteAddress}` const baseProbe = greTunnelProbe(t) const groupKey = peer?.id ?? (normalizeGreEndpointAddr(t.remoteAddress) || t.remoteAddress) const isPrimary = grePrimaryByGroupKey.get(groupKey) === t.id const spGre = fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe) const pc = pingColor(merged.pingMs) const showMetrics = merged.pingMs != null || (merged.dlMbps != null && merged.ulMbps != null) return (
{t.name}
{isPrimary ? "primary" : "backup"} {t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
→ {peer ? `${peer.name} · ${peer.site}` : t.remoteAddress} {t.ipsec ? IPsec : Plain}
{showMetrics && (
{merged.hasSpeedMonitor ? "Мониторинг (ping / TX·RX)" : "Модель ping / speed"} {merged.pingMs != null ? `${merged.pingMs} мс` : "—"} {merged.dlMbps != null && merged.ulMbps != null && ( ↓{merged.dlMbps} ↑{merged.ulMbps} )}
)}
) })}
)} {/* Resources */} {(() => { const res = srvResMap[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 (

Ресурсы

{!res.fromPoll && (

Нет снапшота опроса — показаны демо-значения. Включите опрос серверов или откройте «Серверы».

)} {res.fromPoll && (

CPU, RAM и uptime — с последнего poll MikroTik. Диск в опросе не передаётся, шкала условная.

)} {/* CPU with sparkline */}
CPU {cpuPct}%
{/* RAM */}
RAM {fmtMB(res.ramUsed)} / {fmtMB(res.ramTotal)}
{/* HDD (нет в GET /api/servers — всегда условная шкала) */}
HDD {res.fromPoll && ( (оценка) )} {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
)}
Плата {selected.model || "—"}
) })()} {/* Navigate hint */}
) : null}
)}
{/* ── Context menu ── */} {ctxMenu && setCtxMenu(null)} />}
) }