"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, clipSegmentCircleToRect, computeNetworkMapLayout, MAP_SERVICE_NODE_H, MAP_SERVICE_NODE_W, NETWORK_MAP_H, NETWORK_MAP_LAYOUT_REVISION, NETWORK_MAP_PIPELINE_Y, NETWORK_MAP_W, findServerByGreRemote, greSourceWanIndexOnMap, greTunnelProbe, placeServiceNodes, 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 { formatNetflowDir, formatNetflowRate, hopHasRate, matchNetflowForGreEdge, matchNetflowForWan, type MatchedNetflowHop, } from "@/lib/map-netflow-hops" import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow" import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon" 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 { formatServicePathLabel, formatServicePathTitle } from "@/lib/format-service-path-label" 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 = NETWORK_MAP_W const H = NETWORK_MAP_H 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", } const MOCK_MAP_SERVICES: FlowMapService[] = [ { id: "svc:google", label: "Google", category: "Веб", bytes: 22_000_000, bps: 8_800_000, share: 0.22 }, { id: "svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 14_000_000, bps: 5_600_000, share: 0.14 }, { id: "svc:aws", label: "AWS", category: "CDN", bytes: 9_000_000, bps: 3_600_000, share: 0.09 }, ] const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [ { fromId: "srv2", toId: "svc:google", bytes: 14_000_000, bps: 5_600_000, bpsFwd: 4_200_000, bpsRev: 1_400_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] }, { fromId: "srv3", toId: "svc:google", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] }, { fromId: "srv2", toId: "svc:cloudflare", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_800_000, bpsRev: 800_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] }, { fromId: "srv3", toId: "svc:cloudflare", bytes: 5_000_000, bps: 2_000_000, bpsFwd: 1_500_000, bpsRev: 500_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] }, { fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] }, ] const MOCK_MAP_SERVICE_PATHS: FlowMapServicePath[] = [ { clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "svc:google", bytes: 14_000_000, bps: 5_600_000 }, { clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:google", bytes: 8_000_000, bps: 3_200_000 }, { clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "svc:cloudflare", bytes: 9_000_000, bps: 3_600_000 }, { clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:cloudflare", bytes: 5_000_000, bps: 2_000_000 }, { clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:aws", bytes: 9_000_000, bps: 3_600_000 }, ] function servicePathKey(p: Pick): string { return `${p.clientId}|${p.viaId}|${p.enId}|${p.serviceId}` } function greMatchesPath(e: GreMapEdge, h: { viaId: string; enId: string }): boolean { const ids = new Set([e.fromServer.id, e.toServer.id]) return ids.has(h.viaId) && ids.has(h.enId) } function serviceSharePct(share: number): string { return `${Math.round(share * 100)}%` } function exitNodeIdsFromGre(greEdges: GreMapEdge[]): string[] { const ids = new Set() for (const g of greEdges) { if (g.fromServer.type === "exit-node") ids.add(g.fromServer.id) if (g.toServer.type === "exit-node") ids.add(g.toServer.id) } return [...ids] } function remapServiceEdgeFromId( fromId: string, servers: Server[], greEdges: GreMapEdge[], soleEnId: string | null, ): string | null { const srv = servers.find((s) => s.id === fromId) if (srv?.type === "exit-node") return fromId for (const g of greEdges) { if (g.fromServer.id === fromId && g.toServer.type === "exit-node") return g.toServer.id if (g.toServer.id === fromId && g.fromServer.type === "exit-node") return g.fromServer.id } return soleEnId } /** API-рёбра на EN; если hop нет — синтез от единственного EN на карте (GRE / каталог). */ function drawableServiceEdges( services: FlowMapService[], edges: FlowMapServiceEdge[], servers: Server[], greEdges: GreMapEdge[], nodePosById: Record, ): FlowMapServiceEdge[] { const keep = new Set(services.map((s) => s.id)) const greEn = exitNodeIdsFromGre(greEdges) const catalogEn = servers.filter((s) => s.type === "exit-node").map((s) => s.id) const enPool = greEn.length > 0 ? greEn : catalogEn const soleEnId = enPool.length === 1 ? enPool[0]! : null const merged = new Map() function bump(e: FlowMapServiceEdge) { const key = `${e.fromId}|${e.toId}` const prev = merged.get(key) if (!prev) { merged.set(key, { ...e }) return } merged.set(key, { ...prev, bytes: prev.bytes + e.bytes, bps: prev.bps + e.bps, bpsFwd: prev.bpsFwd + e.bpsFwd, bpsRev: prev.bpsRev + e.bpsRev, }) } for (const e of edges) { if (!keep.has(e.toId)) continue const fromId = remapServiceEdgeFromId(e.fromId, servers, greEdges, soleEnId) if (!fromId || !nodePosById[fromId]) continue bump({ ...e, fromId }) } const covered = new Set([...merged.values()].map((e) => e.toId)) if (soleEnId && nodePosById[soleEnId]) { for (const svc of services) { if (covered.has(svc.id)) continue bump({ fromId: soleEnId, toId: svc.id, bytes: svc.bytes, bps: svc.bps, bpsFwd: svc.bps, bpsRev: 0, }) } } return [...merged.values()] } // ─── 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}`} )} ) } /** Живой поток NetFlow (не ёмкость канала / не BT). */ function NetflowRateBadge({ mx, my, hop, onOpen, }: { mx: number my: number hop: MatchedNetflowHop onOpen?: (e: React.MouseEvent) => void }) { const showDir = hop.bpsFwd > 0 && hop.bpsRev > 0 const bw = showDir ? 86 : 72 const bh = showDir ? 32 : 20 return ( { e.stopPropagation() }} onClick={(e) => { e.stopPropagation(); onOpen?.(e) }} > Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно. {formatNetflowRate(hop)} {showDir && ( {formatNetflowDir(hop)} )} ) } 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 ServiceNode({ label, share, x, y, isSel, isVis, isDragged, onClick, onMouseDown, }: { label: string share: number x: number y: number isSel: boolean isVis: boolean isDragged: boolean onClick: () => void onMouseDown: (e: React.MouseEvent) => void }) { const bw = MAP_SERVICE_NODE_W const bh = MAP_SERVICE_NODE_H return ( { e.stopPropagation(); onMouseDown(e) }} onClick={(e) => { e.stopPropagation(); onClick() }} > {`${label} · ${serviceSharePct(share)} трафика окна`} {isSel && ( )} {label} {serviceSharePct(share)} ) } function ServicePathList({ paths, servers, services, highlight, viaMode, onToggle, }: { paths: FlowMapServicePath[] servers: Server[] services: FlowMapService[] highlight: { viaId: string; enId: string; serviceId: string } | null viaMode: "via" | "service" onToggle: (p: FlowMapServicePath) => void }) { if (paths.length === 0) { return

} return (
{paths.map((p) => { const rowKey = servicePathKey(p) const via = servers.find((s) => s.id === p.viaId) const en = servers.find((s) => s.id === p.enId) const svc = services.find((s) => s.id === p.serviceId) const label = formatServicePathLabel(p, viaMode, { viaName: via?.name, viaSite: via?.site, enName: en?.name, serviceLabel: svc?.label, }) const title = formatServicePathTitle(label, svc?.label ?? p.serviceId) const active = Boolean( highlight && highlight.viaId === p.viaId && highlight.enId === p.enId && highlight.serviceId === p.serviceId, ) return ( ) })}
) } 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, servicePos, onClose, onPan }: { pan: { x: number; y: number }; zoom: number nodes: (Server & { x: number; y: number })[] greEdges: GreMapEdge[] satPos: Record wanJhEdges: WanJhEdge[] homeRouters: Server[] servicePos: 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 */} {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) => ( )) )} {Object.entries(servicePos).map(([id, p]) => ( ))} {/* 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([]) const [mapHops, setMapHops] = useState([]) const [mapServices, setMapServices] = useState([]) const [mapServiceEdges, setMapServiceEdges] = useState([]) const [mapServicePaths, setMapServicePaths] = useState([]) const [mapSharePct, setMapSharePct] = useState(5) /** 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({}) setMapHops([]) setMapServices(MOCK_MAP_SERVICES) setMapServiceEdges(MOCK_MAP_SERVICE_EDGES) setMapServicePaths(MOCK_MAP_SERVICE_PATHS) setMapSharePct(5) 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 [selectedService, setSelectedService] = useState(null) const liveSelectedService = selectedService ? (mapServices.find((s) => s.id === selectedService.id) ?? selectedService) : null const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null) const [selWanIdx, setSelWanIdx] = useState(null) const [hoveredId, setHoveredId] = useState(null) // ── Node positions (overrides POS defaults) ───────────────────────────────── const [nodePositions, setNodePositions] = useState>({}) const [satPositions, setSatPositions] = useState>({}) const [servicePositions, setServicePositions] = 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({}) setServicePositions({}) 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 [showNetflow, setShowNetflow] = useState(true) const [showServices, setShowServices] = useState(true) const [showAnimDots, setShowAnimDots] = useState(true) const [showMinimap, setShowMinimap] = useState(true) const [showHints, setShowHints] = useState(false) const [showLayers, setShowLayers] = useState(false) useEffect(() => { if (!useLiveData) { queueMicrotask(() => { setMapHops([]) setMapServices(MOCK_MAP_SERVICES) setMapServiceEdges(MOCK_MAP_SERVICE_EDGES) setMapServicePaths(MOCK_MAP_SERVICE_PATHS) setMapSharePct(5) }) return } if (!showNetflow && !showServices) { queueMicrotask(() => { setMapHops([]) setMapServices([]) setMapServiceEdges([]) setMapServicePaths([]) }) return } let cancelled = false let ac: AbortController | null = null const tick = () => { ac?.abort() ac = new AbortController() apiFetch("/api/traffic/flow/map-hops?range=5m", { signal: ac.signal }) .then((res) => { if (cancelled) return setMapHops(res.hops ?? []) setMapServices(res.services ?? []) setMapServiceEdges(res.serviceEdges ?? []) setMapServicePaths(res.servicePaths ?? []) if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct) }) .catch((err: unknown) => { if (cancelled) return const name = err instanceof Error ? err.name : "" if (name === "AbortError") return }) } tick() const id = window.setInterval(tick, 4000) return () => { cancelled = true ac?.abort() window.clearInterval(id) } }, [useLiveData, showNetflow, showServices, apiFetch]) 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 netflowByGreKey = useMemo(() => { const m = new Map() if (!showNetflow) return m for (const e of greEdges) { const hop = matchNetflowForGreEdge(e, mapHops) if (hopHasRate(hop)) m.set(greEdgeKey(e), hop) } return m }, [greEdges, mapHops, showNetflow]) const netflowByWanKey = useMemo(() => { const m = new Map() if (!showNetflow) return m for (const home of homeRouters) { for (const [wIdx, wan] of (home.wanUplinks ?? []).entries()) { const hop = matchNetflowForWan(home.id, wan.iface, mapHops) if (!hopHasRate(hop)) continue m.set(`${home.id}\t${wIdx}`, hop) for (const e of wanJhEdges) { if (e.homeId === home.id && e.wanIdx === wIdx) m.set(wanJhEdgeMapKey(e), hop) } } } return m }, [homeRouters, wanJhEdges, mapHops, showNetflow]) const visibleMapServices = showServices ? mapServices : [] const visibleServiceEdges = showServices ? drawableServiceEdges(visibleMapServices, mapServiceEdges, mapServers, greEdges, nodePosById) : [] 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])) const autoServicePos = placeServiceNodes( visibleMapServices.map((s) => s.id), mapServers .filter((s) => s.type === "exit-node") .map((s) => nodePosById[s.id]) .filter((p): p is { x: number; y: number } => Boolean(p)), ) const servicePosById = Object.fromEntries( visibleMapServices.map((s) => [s.id, servicePositions[s.id] ?? autoServicePos[s.id]!]), ) // ── 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 } | { kind: "service"; svcId: string; 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 [draggedSvcId, setDraggedSvcId] = useState(null) 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) setSelectedService(null) setHighlightedPath(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 if (nd.kind === "service") { setServicePositions(prev => ({ ...prev, [nd.svcId]: { 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) setDraggedSvcId(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) setSelectedService(null) setHighlightedPath(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}`) } function onServiceMouseDown(e: React.MouseEvent, svcId: string, x: number, y: number) { nodeDragRef.current = { kind: "service", svcId, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false } setDraggedSvcId(svcId) } // ── 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) setSelectedService(null) setHighlightedPath(null) setSelected(prev => prev?.id === s.id ? null : s) setSelWanIdx(null) setHoveredId(null) } function selectService(svc: FlowMapService) { setSelectedGreEdge(null) setSelected(null) setSelWanIdx(null) setHighlightedPath(null) setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc) } function selectWan(s: Server, wanIdx: number) { setSelectedGreEdge(null) setSelectedService(null) setHighlightedPath(null) setSelected(s) setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx) } function togglePathHighlight(p: FlowMapServicePath) { setHighlightedPath((prev) => ( prev && prev.viaId === p.viaId && prev.enId === p.enId && prev.serviceId === p.serviceId ? null : { viaId: p.viaId, enId: p.enId, serviceId: p.serviceId } )) } function openWanJhSpeedDetail(ev: React.MouseEvent, edge: WanJhEdge) { ev.stopPropagation() const home = mapServers.find((s) => s.id === edge.homeId) if (!home) return setSelectedGreEdge(null) setSelectedService(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: "showNetflow", label: "NetFlow", val: showNetflow, set: setShowNetflow, hint: "" }, { key: "showServices", label: "Сервисы", val: showServices, set: setShowServices, hint: "" }, { 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 => ( ))}

{mapSharePct > 0 ? `Порог доли сервиса ≥ ${mapSharePct}% · Настройки → NetFlow` : "Порог доли выключен (все бренды, макс. 20) · Настройки → NetFlow"}

{(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0 || Object.keys(servicePositions).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, ) const flowHop = netflowByGreKey.get(edgeId) const flowPos = edgeBadgePosition( e.from.x, e.from.y, e.to.x, e.to.y, tBadge, -normalPx - (normalPx === 0 ? 22 : 0), ) function openGreDetail(ev: React.MouseEvent) { ev.stopPropagation() setSelectedGreEdge(e) setSelected(null) setSelectedService(null) setHighlightedPath(null) setSelWanIdx(null) } return ( { ev.stopPropagation() }} onClick={openGreDetail} > GRE: подробнее {showAnimDots && ts.dotOpacity > 0 && ( )} {showPingBadges && greTunnelIdsWithMapBadge.has(edgeId) && ( )} {showNetflow && hopHasRate(flowHop) && ( )} ) })} {/* ── 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 flowPos = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.38, 18) const isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx) const wanFlow = netflowByWanKey.get(wanJhEdgeMapKey(edge)) 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 })()} {showNetflow && hopHasRate(wanFlow) && ( openWanJhSpeedDetail(ev, edge)} /> )} ) })} {/* ── 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) })} {visibleMapServices.map((svc) => { const pos = servicePosById[svc.id] if (!pos) return null return ( onServiceMouseDown(e, svc.id, pos.x, pos.y)} onClick={() => { if (suppressClickRef.current) { suppressClickRef.current = false; return } selectService(svc) }} /> ) })} {/* ── EN → destination services (поверх узлов, чтобы пунктир не прятался) ── */} {visibleServiceEdges.map((edge) => { const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId] const to = servicePosById[edge.toId] if (!from || !to) return null const hop: MatchedNetflowHop = { bytes: edge.bytes, bps: edge.bps, bpsFwd: edge.bpsFwd, bpsRev: edge.bpsRev, } const fromR = "type" in from && from.type ? TYPE_STYLE[from.type].r : TYPE_STYLE["exit-node"].r const clipped = clipSegmentCircleToRect( from.x, from.y, fromR, to.x, to.y, MAP_SERVICE_NODE_W / 2, MAP_SERVICE_NODE_H / 2, ) const { mx, my } = edgeBadgePosition(clipped.x1, clipped.y1, clipped.x2, clipped.y2, 0.55, 16) const pathHit = Boolean( highlightedPath && highlightedPath.enId === edge.fromId && highlightedPath.serviceId === edge.toId, ) const pathDim = Boolean(highlightedPath) && !pathHit const hl = pathHit || (!highlightedPath && (selectedService?.id === edge.toId || selected?.id === edge.fromId)) const svc = visibleMapServices.find((s) => s.id === edge.toId) const enName = mapServers.find((s) => s.id === edge.fromId)?.name ?? edge.fromId const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—") const pathTitle = `${clientLabel} → ${enName} → ${svc?.label ?? edge.toId}` return ( {pathTitle} { ev.stopPropagation() }} onClick={(ev) => { ev.stopPropagation() if (svc) selectService(svc) }} /> {showAnimDots && hopHasRate(hop) && ( )} {hopHasRate(hop) && ( { ev.stopPropagation() const hit = visibleMapServices.find((s) => s.id === edge.toId) if (hit) selectService(hit) }} /> )} ) })} {/* ── 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 || selectedService) && (
{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}` : "—"}
Поток (NetFlow) {(() => { const hop = netflowByGreKey.get(selectedEdgeId) if (!hopHasRate(hop)) return "—" return hop.bpsFwd > 0 && hop.bpsRev > 0 ? formatNetflowDir(hop) : formatNetflowRate(hop) })()}

{merged.hasSpeedMonitor ? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам. " : "«Модель RTT» и «скорость канала» — демо до появления подходящей speed-пробы в «Мониторинг → скорость». "} Поток — живой NetFlow за 5 мин (как в «Трафик»: без overlay/mesh), не ёмкость канала.

Статус туннеля {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-туннелей ) })()}
) : liveSelectedService ? ( <>

{liveSelectedService.label}

Конечный сервис · {liveSelectedService.category}

Доля окна {serviceSharePct(liveSelectedService.share)}
Скорость {formatNetflowRate({ bytes: liveSelectedService.bytes, bps: liveSelectedService.bps, bpsFwd: liveSelectedService.bps, bpsRev: 0, })}

Выход

{visibleServiceEdges.filter((e) => e.toId === liveSelectedService.id).map((e) => { const src = mapServers.find((s) => s.id === e.fromId) const enPaths = mapServicePaths .filter((p) => p.serviceId === liveSelectedService.id && p.enId === e.fromId) .slice() .sort((a, b) => b.bps - a.bps) return (
{src?.name ?? e.fromId} {formatNetflowRate({ bytes: e.bytes, bps: e.bps, bpsFwd: e.bpsFwd, bpsRev: e.bpsRev })}
) })}
) : 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], ["Канал", `↓${wan.maxDl} ↑${wan.maxUl} Мбит`]].map(([k, v]) => (
{k} {v}
))} {(() => { const hop = netflowByWanKey.get(`${selected.id}\t${wIdx}`) if (!hopHasRate(hop)) return null return (
Поток {hop.bpsFwd > 0 && hop.bpsRev > 0 ? formatNetflowDir(hop) : formatNetflowRate(hop)}
) })()}
{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 greFlow = netflowByGreKey.get(tunnelPanelKey) const showMetrics = merged.pingMs != null || (merged.dlMbps != null && merged.ulMbps != null) || hopHasRate(greFlow) 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} )} {hopHasRate(greFlow) && ( {formatNetflowRate(greFlow)} )}
)}
) })}
)} {(selected.type === "jump-host" || selected.type === "exit-node") && (

Пути

( selected.type === "exit-node" ? p.enId === selected.id : p.viaId === selected.id )) .slice() .sort((a, b) => b.bps - a.bps)} servers={mapServers} services={mapServices} highlight={highlightedPath} viaMode="service" onToggle={togglePathHighlight} />
)} {/* 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)} />}
) }