"use client" import { useState, useRef, useEffect, useMemo, useCallback, Suspense } from "react" import {fitNetworkMapCamera} from "@/lib/network-map-camera" import { useSearchParams } from "next/navigation" import {readNetworkMapUrl,patchNetworkMapUrl,type NetworkMapFilter} from "@/lib/network-map-url-state" import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert" import {Frame,FramePanel} from "@/components/reui/frame" import {Popover,PopoverTrigger,PopoverContent} from "@/components/ui/popover" import {Dialog,DialogContent,DialogHeader,DialogTitle,DialogDescription} from "@/components/ui/dialog" import {usePrefersReducedMotion} from "@/hooks/use-prefers-reduced-motion" import {toast} from "sonner" import { PageHeader } from "@/components/page-header" import { NetworkMapLegend } from "@/components/network-map-legend" import { servers as mockServers, greTunnels as mockGreTunnels, type GreTunnel, type Server, type ServerStatus, type ServerType, } from "@/lib/data" import {validateNetworkMapSnapshot,networkMapFlowSchema,validateNetworkMapDns,type NetworkMapServerRow} from "@/lib/network-map-response" 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, placeCountryServiceNodes, placeServiceNodes, SERVICE_COL_W, 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 { FlowMapCountryServiceGroup, FlowMapHop, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow" import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon" import { CountryFlagSvg } from "@/components/network-map/country-flag-svg" 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, ActivityIcon, } from "lucide-react" import { cn } from "@/lib/utils" import { formatServicePathLabel, formatServicePathTitle } from "@/lib/format-service-path-label" import Link from "next/link" import { Flag, countryName } from "@/components/flag" // ─── Resource metrics (для мини-блока справа; числа детерминированы по id узла) ─ // ─── Backend → frontend (как /servers) ─────────────────────────────────────── type BackendServerRow = NetworkMapServerRow 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 ?? "unknown", latency: 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: { secret: "" } | 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: t.ipsec, 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 | null): string { if(mb==null)return "—" if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ` return Number(mb.toFixed(2))+" МБ" } function fmtUptime(sec: number | null): string { if(sec==null)return "—" 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 | null): string { if(pct==null)return "text-muted-foreground" 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 = { unknown: { fill: "var(--muted)", stroke: "var(--muted-foreground)", text: "var(--muted-foreground)", glow: "transparent" }, online: { fill: "var(--status-online-bg)", stroke: "var(--status-online)", text: "var(--status-online)", glow: "color-mix(in oklch,var(--status-online) 15%,transparent)" }, degraded: { fill: "var(--status-degraded-bg)", stroke: "var(--status-degraded)", text: "var(--status-degraded)", glow: "color-mix(in oklch,var(--status-degraded) 15%,transparent)" }, offline: { fill: "var(--status-offline-bg)", stroke: "var(--status-offline)", text: "var(--status-offline)", glow: "transparent" }, } const TYPE_STYLE: Record = { // HR — визуальный корень топологии: крупнее и с более сильным акцентом. "home-router": { label: "HR", fill: "var(--chart-2)", r: 40 }, // JH — агрегирующий слой по центру. "jump-host": { label: "JH", fill: "var(--chart-1)", r: 34 }, // EN — крайние edge-узлы, менее акцентные. "exit-node": { label: "EN", fill: "var(--chart-3)", r: 30 }, } const WAN_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"] const TUNNEL_STYLE = { up: { stroke: "var(--status-online)", opacity: 0.5, dotOpacity: 0.85 }, degraded: { stroke: "var(--status-degraded)", opacity: 0.4, dotOpacity: 0.7 }, down: { stroke: "var(--status-offline)", 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_COUNTRIES: FlowMapService[] = [ { id: "cc:us", label: "US", category: "Страна", bytes: 22_000_000, bps: 8_800_000, share: 0.38 }, { id: "cc:nl", label: "NL", category: "Страна", bytes: 14_000_000, bps: 5_600_000, share: 0.31 }, { id: "cc:de", label: "DE", category: "Страна", bytes: 9_000_000, bps: 3_600_000, share: 0.21 }, ] 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_COUNTRY_EDGES: FlowMapServiceEdge[] = [ { fromId: "srv2", toId: "cc:us", 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: "cc:us", 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: "cc:nl", 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: "cc:nl", 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: "cc:de", 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 }, ] const MOCK_MAP_COUNTRY_PATHS: FlowMapServicePath[] = [ { clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us", 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: "cc:us", 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: "cc:nl", 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: "cc:nl", 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: "cc:de", bytes: 9_000_000, bps: 3_600_000 }, ] /** Доли — от байтов страны cc:us (22M из моков выше). */ const MOCK_COUNTRY_SERVICE_GROUPS: FlowMapCountryServiceGroup[] = [ { countryId: "cc:us", services: [ { id: "cc:us|svc:google", label: "Google", category: "Веб", bytes: 12_000_000, bps: 4_800_000, share: 12 / 22 }, { id: "cc:us|svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 7_000_000, bps: 2_800_000, share: 7 / 22 }, { id: "cc:us|svc:aws", label: "AWS", category: "CDN", bytes: 3_000_000, bps: 1_200_000, share: 3 / 22 }, ], edges: [ { fromId: "cc:us", toId: "cc:us|svc:google", bytes: 12_000_000, bps: 4_800_000, bpsFwd: 9_000_000, bpsRev: 3_000_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] }, { fromId: "cc:us", toId: "cc:us|svc:cloudflare", bytes: 7_000_000, bps: 2_800_000, bpsFwd: 5_250_000, bpsRev: 1_750_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] }, { fromId: "cc:us", toId: "cc:us|svc:aws", bytes: 3_000_000, bps: 1_200_000, bpsFwd: 2_250_000, bpsRev: 750_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] }, ], paths: [ { clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us|svc:google", bytes: 8_000_000, bps: 3_200_000 }, { clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:us|svc:google", bytes: 4_000_000, bps: 1_600_000 }, { clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us|svc:cloudflare", bytes: 7_000_000, bps: 2_800_000 }, { clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:us|svc:aws", bytes: 3_000_000, bps: 1_200_000 }, ], }, ] const DEST_MODE_KEY = "mm-network-map-dest-mode" type DestMode = "services" | "countries" function destDisplayLabel(node: FlowMapService | undefined, mode: DestMode, fallback = ""): string { if (!node) return fallback if (mode !== "countries") return node.label if (node.id === "cc:other" || node.label === "Прочее") return "Прочее" return countryName(node.label) } 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 "var(--muted-foreground)" if (ms < 15) return "var(--status-online)" if (ms < 50) return "var(--status-degraded)" return "var(--status-degraded)" } function latencyColor(ms: number | null) { if (ms == null) return "var(--muted-foreground)" if (ms > 60) return "var(--status-degraded)" return "var(--status-online)" } function shortName(name: string, limit = 24) { const label = name.replace(/^(home|mt)-/, "").split("-").slice(0, 3).join("-") return label.length > limit ? label.slice(0, limit - 1).trimEnd() + "…" : label } 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 = NetworkMapFilter 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, probe, }: { mx: number my: number pingMs: number | null dl: number | null ul: number | null onOpen: (e: React.SyntheticEvent) => void /** Данные из Мониторинг → скорость (uptime speed-probes) */ rttFromMonitor?: boolean throughputFromMonitor?: boolean /** Внешние IP GRE и WAN — только в подсказке при наведении */ outerSummary?: string probe?: GreSpeedProbeSnapshot }) { const hasSpeed = dl != null || ul != null const bw = 62 const bh = 24 + (hasSpeed ? 14 : 0) const v1 = pingMs == null ? "—" : `${pingMs} мс` const hint = (outerSummary ? `${outerSummary}. ` : "") + (rttFromMonitor ? "RTT — ping/BT из «Мониторинг → скорость». " : pingMs==null?"RTT неизвестен. ":"RTT — демонстрационная модель. ") + (throughputFromMonitor ? "Строка ниже — TX/RX с пробы. " : "") + (probe?"Проба "+probe.id+"; время Ping: "+(probe.lastPingAt?new Date(probe.lastPingAt).toLocaleString("ru-RU"):"неизвестно")+"; время TX/RX неизвестно. ":"") + "Клик — панель справа." return ( {if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onOpen(e)}}} transform={`translate(${mx},${my})`} style={{ cursor: "pointer" }} onPointerDown={(e) => { 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.SyntheticEvent) => void }) { const showDir = hop.bpsFwd > 0 && hop.bpsRev > 0 const bw = showDir ? 86 : 72 const bh = showDir ? 32 : 20 return ( {if(onOpen&&(e.key==="Enter"||e.key===" ")){e.preventDefault();e.stopPropagation();onOpen(e)}}} transform={`translate(${mx},${my})`} style={{ cursor: onOpen ? "pointer" : "default" }} onPointerDown={(e) => { 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.country?.toUpperCase() || "—"}`, n.latency != null ? `${n.latency} мс` : "Офлайн", n.model ?? "", ].filter(Boolean) const bw = 148, bh = lines.length * 14 + 14 return ( ) } function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onPointerDown }: { n: Server & { x: number; y: number } labelScale?: number isSel: boolean; isVis: boolean; isDragged: boolean /** Не дублировать «N мс» из каталога, если на карте уже показаны метрики GRE (оранжевые числа в круге). */ hideCatalogLatency?: boolean onClick: () => void onDblClick: () => void onHoverChange: (hov: boolean) => void onContextMenu: (e: React.MouseEvent) => void onPointerDown: (e: React.PointerEvent) => void }) { const ss = STATUS_STYLE[n.status] const ts = TYPE_STYLE[n.type] return ( {if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}} transform={`translate(${n.x},${n.y})`} style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }} opacity={isVis ? 1 : 0.07} onPointerDown={e => { e.stopPropagation(); onPointerDown(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: "var(--foreground)", lineHeight: 1.25, }} > {n.site || n.country?.toUpperCase() || "—"}
) : ( 32 ? "13" : "11"} fontWeight="700" fill="var(--foreground)" fontFamily="ui-monospace,monospace">{n.site || n.country?.toUpperCase() || "—"} )} {!hideCatalogLatency && ( {n.latency == null ? "—" : `${n.latency} мс`} )} {(n.name && ( {shortName(n.name,labelScale>1?12:24)} ))}
) } function ServiceNode({ label, share, x, y, isSel, isVis, isDragged, destMode, iso, dim, shareLabel, onClick, onPointerDown, }: { label: string share: number x: number y: number isSel: boolean isVis: boolean isDragged: boolean destMode: DestMode iso?: string /** Приглушение узла при раскрытии другой страны (остаётся на холсте). */ dim?: boolean /** Подпись доли в tooltip: у вложенных сервисов — доля страны, не окна. */ shareLabel?: string onClick: () => void onPointerDown: (e: React.PointerEvent) => void }) { const bw = MAP_SERVICE_NODE_W const bh = MAP_SERVICE_NODE_H const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : "" return ( {if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}} transform={`translate(${x},${y})`} style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }} opacity={isVis ? (dim ? 0.35 : 1) : 0.08} onPointerDown={(e) => { e.stopPropagation(); onPointerDown(e) }} onClick={(e) => { e.stopPropagation(); onClick() }} > {`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`} {isSel && ( )} {flagIso ? : {label} {serviceSharePct(share)} ) } function ServicePathList({ paths, servers, services, highlight, viaMode, destMode, onToggle, }: { paths: FlowMapServicePath[] servers: Server[] services: FlowMapService[] highlight: { viaId: string; enId: string; serviceId: string } | null viaMode: "via" | "service" destMode: DestMode 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 destLabel = destDisplayLabel(svc, destMode, p.serviceId) const label = formatServicePathLabel(p, viaMode, { viaName: via?.name, viaSite: via?.site, enName: en?.name, serviceLabel: destLabel, }) const title = formatServicePathTitle(label, destLabel) 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, onPointerDown }: { x: number; y: number wan: { name: string; isp: string; maxDl: number; maxUl: number } color: string; active: boolean; isSel: boolean; isDragged: boolean onSelect: () => void onPointerDown: (e: React.PointerEvent) => void }) { const r = 18 return ( {if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}} transform={`translate(${x},${y})`} style={{ cursor: isDragged ? "grabbing" : "grab" }} onPointerDown={e => { e.stopPropagation(); onPointerDown(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: