"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 (
{/* colour accent line */}
{lines.map((l, i) => (
{l}
))}
)
}
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: ,
label: "Открыть терминал",
href: `/terminal?host=${encodeURIComponent(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 (
)
}
// ─── Minimap ──────────────────────────────────────────────────────────────────
const MM_W = 172, MM_H = 94
function Minimap({ viewportHeight, pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, servicePos, onClose, onPan }: {
viewportHeight: number
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, viewportHeight / 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 (
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() {
return Загрузка контекста карты…
}>
}
function NetworkMapRoute(){
const {mode,backendUrl}=useDataSource()
const params=useSearchParams()
const state=readNetworkMapUrl(new URLSearchParams(params.toString()))
if(state.error)return Контекст карты недоступен{state.error}
return
}
function NetworkMapContent() {
const params=useSearchParams()
const serializedParams=params.toString()
const urlState=useMemo(()=>readNetworkMapUrl(new URLSearchParams(serializedParams)),[serializedParams])
const updateUrl=useCallback((values:Record,replace=false)=>{
const href=patchNetworkMapUrl(new URLSearchParams(window.location.search),values)+window.location.hash
if(href!==window.location.pathname+window.location.search+window.location.hash)(replace?window.history.replaceState:window.history.pushState).call(window.history,null,"",href)
},[])
const { mode, backendUrl } = useDataSource()
const useLiveData = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [mapServers, setMapServers] = useState([])
const [sessionCounts,setSessionCounts]=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 [mapCountries, setMapCountries] = useState([])
const [mapCountryEdges, setMapCountryEdges] = useState([])
const [mapCountryPaths, setMapCountryPaths] = useState([])
const [mapCountryServiceGroups, setMapCountryServiceGroups] = useState([])
const [mapSharePct, setMapSharePct] = useState(5)
const [mapNamedBytes, setMapNamedBytes] = useState(0)
const [mapTotalBytes, setMapTotalBytes] = useState(0)
const [mapWindowSec, setMapWindowSec] = useState(300)
const [mapAsnLoaded, setMapAsnLoaded] = useState(true)
const [mapCountryLoaded, setMapCountryLoaded] = useState(true)
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState>({})
const [flowError,setFlowError]=useState(null)
const [flowReadAt,setFlowReadAt]=useState(null)
const [flowListener,setFlowListener]=useState(false)
const [flowLoading,setFlowLoading]=useState(false)
const [flowRefresh,setFlowRefresh]=useState(0)
const [dnsError,setDnsError]=useState(null)
const [dataError, setDataError] = useState(null)
const [dataLoading, setDataLoading] = useState(false)
const [hasSnapshot,setHasSnapshot]=useState(false)
const [readAt,setReadAt]=useState(null)
const reader=useRef({alive:true,epoch:0})
useEffect(()=>{const state=reader.current;state.alive=true;return()=>{state.alive=false;state.epoch++}},[])
const greResolvedMap = useMemo(
() => new Map(Object.entries(greResolvedIpv4ByHost)) as ReadonlyMap,
[greResolvedIpv4ByHost],
)
const loadLive = useCallback(async () => {
if (!useLiveData) return
const epoch=++reader.current.epoch
const current=()=>reader.current.alive&&reader.current.epoch===epoch
if(!current())return
setDataLoading(true)
try {
const parts=await Promise.allSettled([
apiFetch("/api/servers"),
apiFetch("/api/filters/gre-tunnels"),
apiFetch("/api/uptime/speed-probes"),
])
if(!current())return
const labels=["Устройства","Каталог GRE","Измерения"]
const failed=parts.flatMap((part,index)=>part.status==="rejected"?[labels[index]+": "+(part.reason instanceof Error?part.reason.message:"ошибка API")]:[])
if(failed.length)throw new Error(failed.join("; "))
let snapshot:ReturnType
try {snapshot=validateNetworkMapSnapshot(...parts.map(part=>part.status==="fulfilled"?part.value:null) as [unknown,unknown,unknown])}
catch(error){throw new Error("Недостоверный снимок карты: "+(error instanceof Error?(error.name==="ZodError"?"неполные или некорректные поля API":error.message):"ошибка данных"))}
const rows=snapshot.servers
const mappedTunnels=snapshot.tunnels.map(apiGreToGreTunnel)
if(!current())return
setMapServers(rows.map(mapBackendRowToServer));setMapGreTunnels(mappedTunnels)
setSessionCounts(Object.fromEntries(rows.map(row=>[String(row.id),row.sessions??null])))
setSpeedProbes(snapshot.probes.map(probe=>({...probe,srcInterface:probe.srcInterface||undefined,dstInterface:probe.dstInterface||undefined})))
setHasSnapshot(true);setReadAt(new Date().toISOString());setDataError(null)
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]
setGreResolvedIpv4ByHost({});setDnsError(null)
if (hostnames.length > 0) {
try {
const res = await apiFetch(
"/api/network/resolve-hosts",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hosts: hostnames }),
},
)
if(!current())return
const results=validateNetworkMapDns(res,hostnames)
setGreResolvedIpv4ByHost(Object.fromEntries(greResolvedMapFromApi(results)))
const missing=Object.keys(results).filter(host=>results[host]===null)
setDnsError(missing.length?"DNS не разрешён: "+missing.join(", "):null)
} catch(error) {
if(!current())return
setGreResolvedIpv4ByHost({})
setDnsError(error instanceof Error?(error.name==="ZodError"?"Некорректный ответ разрешения имён":error.message):"Ошибка DNS")
}
} else {
setGreResolvedIpv4ByHost({})
}
} catch (e) {
if(current())setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
} finally {
if(current())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)
setMapCountries(MOCK_MAP_COUNTRIES)
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
setMapCountryServiceGroups(MOCK_COUNTRY_SERVICE_GROUPS)
setMapSharePct(5)
setMapNamedBytes(0)
setMapTotalBytes(0)
setMapCountryLoaded(true)
setDataError(null)
})
return
}
queueMicrotask(() => {
void loadLive()
})
}, [useLiveData, loadLive])
const autoLayout = useMemo(
() => computeNetworkMapLayout(mapServers, mapGreTunnels, greResolvedMap),
[mapServers, mapGreTunnels, greResolvedMap],
)
const wanJhEdges = useMemo(() => buildWanJhEdges(mapServers).map(edge=>({...edge,active:!useLiveData&&edge.active})), [mapServers,useLiveData])
const srvResMap = useMemo(() => buildServerResourceMap(mapServers, !useLiveData), [mapServers,useLiveData])
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 [viewportHeight,setViewportHeight]=useState(H)
const [canvasMeasured,setCanvasMeasured]=useState(false)
const [canvasWidth,setCanvasWidth]=useState(W)
const [isDragging, setIsDragging] = useState(false)
// ── Interaction ─────────────────────────────────────────────────────────────
const selected=urlState.server?mapServers.find(server=>server.id===urlState.server)??null:null
const setSelected=useCallback((value:React.SetStateAction)=>{
const next=typeof value==="function"?value(selected):value
updateUrl({server:next?.id??null,wan:null})
},[selected,updateUrl])
const [selectedService, setSelectedService] = useState(null)
/** Раскрытая страна (режим «Страны»): справа столбец её сервисов. Не персистится. */
const [expandedCountryId, setExpandedCountryId] = useState(null)
const destMode=urlState.dest
function setDestMode(mode: DestMode) {
updateUrl({dest:mode==="services"?null:mode})
setSelectedService(null)
setExpandedCountryId(null)
setHighlightedPath(null)
try { sessionStorage.setItem(DEST_MODE_KEY, mode) } catch { /* private mode */ }
}
const expandedCountryGroup = useMemo(
() => destMode === "countries" && expandedCountryId
? mapCountryServiceGroups.find((g) => g.countryId === expandedCountryId) ?? null
: null,
[destMode, expandedCountryId, mapCountryServiceGroups],
)
const nestedServices = useMemo(() => expandedCountryGroup?.services ?? [], [expandedCountryGroup])
const liveSelectedService = selectedService
? (
(destMode === "countries" ? mapCountries : mapServices)
.find((s) => s.id === selectedService.id)
?? nestedServices.find((s) => s.id === selectedService.id)
?? selectedService
)
: null
const selectedNestedService = liveSelectedService
&& nestedServices.some((s) => s.id === liveSelectedService.id)
? liveSelectedService
: null
const expandedCountry = expandedCountryId
? mapCountries.find((c) => c.id === expandedCountryId) ?? null
: null
const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null)
const selWanIdx=urlState.wan
function setSelWanIdx(value:React.SetStateAction){const next=typeof value==="function"?value(selWanIdx):value;updateUrl({wan:next==null?null:String(next)})}
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-данных.
queueMicrotask(()=>{setNodePositions({});setSatPositions({});setServicePositions({})})
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
}
} catch {
/* private mode / disabled storage */
}
}, [])
// ── Context menu ────────────────────────────────────────────────────────────
const [ctxMenu, setCtxMenu] = useState(null)
const currentCtxServer=ctxMenu?mapServers.find(server=>server.id===ctxMenu.server.id):null
useEffect(()=>{if(ctxMenu&&!currentCtxServer)queueMicrotask(()=>setCtxMenu(null))},[ctxMenu,currentCtxServer])
const [selectedGreStored, setSelectedGreEdge] = useState(null)
// ── Toggles ─────────────────────────────────────────────────────────────────
const filter=urlState.filter
const search=urlState.search
const setFilter=(value:FilterKey)=>updateUrl({filter:value==="all"?null:value})
const setSearch=(value:string)=>updateUrl({q:value||null},true)
const [showPingBadges, setShowPingBadges] = useState(true)
const [showNetflow, setShowNetflow] = useState(true)
const [showServices, setShowServices] = useState(true)
const reducedMotion=usePrefersReducedMotion()
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)
setMapCountries(MOCK_MAP_COUNTRIES)
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
setMapCountryServiceGroups(MOCK_COUNTRY_SERVICE_GROUPS)
setMapSharePct(5)
setMapCountryLoaded(true)
})
return
}
if (!showNetflow && !showServices) return
let cancelled = false
let epoch=0
let inFlight=false
let ac: AbortController | null = null
const tick = () => {
if(cancelled||inFlight)return
inFlight=true
const request=++epoch
setFlowLoading(true)
ac?.abort()
ac = new AbortController()
apiFetch("/api/traffic/flow/map-hops?range=5m", { signal: ac.signal })
.then((raw) => {
if (cancelled||request!==epoch) return
const res=networkMapFlowSchema.parse(raw)
setMapHops(res.hops)
setMapServices(res.services)
setMapServiceEdges(res.serviceEdges)
setMapServicePaths(res.servicePaths)
setMapCountries(res.countries)
setMapCountryEdges(res.countryEdges)
setMapCountryPaths(res.countryPaths)
setMapCountryServiceGroups(res.countryServiceGroups)
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
setMapNamedBytes(res.namedBytes)
setMapTotalBytes(res.totalBytes)
if (res.asnLoaded != null) setMapAsnLoaded(res.asnLoaded)
if (res.countryLoaded != null) setMapCountryLoaded(res.countryLoaded)
setMapWindowSec(res.windowSec)
setFlowListener(res.live);setFlowReadAt(new Date().toISOString());setFlowError(null)
})
.catch((err: unknown) => {
if (cancelled||request!==epoch) return
const name = err instanceof Error ? err.name : ""
if (name === "AbortError") return
setFlowError(err instanceof Error?(err.name==="ZodError"?"Некорректный ответ: требуется полный снимок за 5 минут":err.message):"Ошибка NetFlow")
}).finally(()=>{inFlight=false;if(!cancelled&&request===epoch)setFlowLoading(false)})
}
queueMicrotask(()=>{if(!cancelled)tick()})
const id = window.setInterval(tick, 4000)
return () => {
cancelled = true;epoch++
ac?.abort()
window.clearInterval(id)
}
}, [useLiveData, showNetflow, showServices, apiFetch,flowRefresh])
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],
)
const selectedGreEdge=selectedGreStored?greEdges.find(edge=>greEdgeKey(edge)===greEdgeKey(selectedGreStored))??null:null
/** 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,!useLiveData))
if (!merged.hasSpeedMonitor) continue
s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`)
}
return s
}, [greEdges, speedProbeByTunnelId, greResolvedMap,useLiveData])
/** 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,!useLiveData))
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,useLiveData])
/** Несколько 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 destNodes = destMode === "countries" ? mapCountries : mapServices
const destEdges = destMode === "countries" ? mapCountryEdges : mapServiceEdges
const destPaths = destMode === "countries" ? mapCountryPaths : mapServicePaths
const visibleMapServices = showServices ? destNodes : []
const visibleServiceEdges = showServices
? drawableServiceEdges(visibleMapServices, destEdges, 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)),
)
// Раскрытая страна: колонка стран уходит влево, правый x занимает столбец её сервисов.
const countryColShift = expandedCountryGroup ? SERVICE_COL_W : 0
const autoDestPos = countryColShift
? Object.fromEntries(
Object.entries(autoServicePos).map(([id, p]) => [id, { x: p.x - countryColShift, y: p.y }]),
)
: autoServicePos
const servicePosById = Object.fromEntries(
visibleMapServices.map((s) => [s.id, servicePositions[s.id] ?? autoDestPos[s.id]!]),
)
const autoNestedPos = placeCountryServiceNodes(
nestedServices.map((s) => s.id),
expandedCountryId ? servicePosById[expandedCountryId] ?? autoDestPos[expandedCountryId] : undefined,
)
const nestedPosById = Object.fromEntries(
nestedServices
.map((s) => [s.id, servicePositions[s.id] ?? autoNestedPos[s.id]] as const)
.filter((entry): entry is readonly [string, { x: number; y: number }] => Boolean(entry[1])),
)
// ── Refs ─────────────────────────────────────────────────────────────────────
const svgRef = useRef(null)
const canvasRef=useRef(null)
const initialFit=useRef(false)
useEffect(()=>{const element=canvasRef.current;if(!element)return;const observer=new ResizeObserver(entries=>{const {width,height}=entries[0].contentRect;if(width<=0||height<=0)return;setViewportHeight(W*height/width);setCanvasWidth(width);setCanvasMeasured(true)});observer.observe(element);return()=>observer.disconnect()},[])
// 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)
useEffect(()=>{zoomRef.current=zoom;panRef.current=pan},[zoom,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.querySelectorAll(".network-map-focus-ring").forEach(ring => ring.remove())
clone.setAttribute("viewBox", `0 0 ${W} ${H}`)
clone.setAttribute("width", String(W))
clone.setAttribute("height", String(H))
clone.setAttribute("xmlns","http://www.w3.org/2000/svg")
const computed=getComputedStyle(svg)
const variables=new Set([...clone.outerHTML.matchAll(/var\((--[a-zA-Z0-9_-]+)/g)].map(match=>match[1]))
variables.add("--background")
for(const name of variables){const value=computed.getPropertyValue(name).trim();if(!value)continue;clone.style.setProperty(name,value);for(const match of value.matchAll(/var\((--[a-zA-Z0-9_-]+)/g))variables.add(match[1])}
clone.style.width=W+"px";clone.style.height=H+"px"
const background=document.createElementNS("http://www.w3.org/2000/svg","rect")
background.setAttribute("width",String(W));background.setAttribute("height",String(H));background.setAttribute("fill","var(--background)");clone.prepend(background)
const blob = new Blob(
[`\n`, new XMLSerializer().serializeToString(clone)],
{ 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 * ((W * rect.height / rect.width) / 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 * ((W * rect.height / rect.width) / newZoom),
})
setZoom(newZoom)
}
svg.addEventListener("wheel", handler, { passive: false })
return () => svg.removeEventListener("wheel", handler)
}, [])
const returnToMapControl=useCallback(()=>{queueMicrotask(()=>document.querySelector("[data-map-device-select]")?.focus())},[])
// ── Zoom helpers ──────────────────────────────────────────────────────────
const applyZoomCenter=useCallback((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 + (viewportHeight / zoom - viewportHeight / newZoom) / 2,
}))
setZoom(newZoom)
},[zoom,viewportHeight])
const {pan:{x:fitX,y:fitY},zoom:fitZoom}=fitNetworkMapCamera([...Object.values(nodePosById),...Object.values(effectiveSatPos).flat(),...Object.values(servicePosById),...Object.values(nestedPosById)],W,viewportHeight)
function fitView(){setPan({x:fitX,y:fitY});setZoom(fitZoom)}
useEffect(()=>{if(initialFit.current||!canvasMeasured||!mapServers.length)return;initialFit.current=true;queueMicrotask(()=>{setPan({x:fitX,y:fitY});setZoom(fitZoom)})},[canvasMeasured,mapServers.length,fitX,fitY,fitZoom])
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 - viewportHeight / (2 * newZoom) })
setZoom(newZoom)
}
// ── Keyboard shortcuts ────────────────────────────────────────────────────
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (ctxMenu||showLayers) return
if (e.key!=="Escape"&&(e.ctrlKey||e.metaKey||e.altKey||e.target instanceof HTMLInputElement||e.target instanceof HTMLTextAreaElement||e.target instanceof HTMLSelectElement))return
if (e.key === "Escape") {
setSelected(null)
updateUrl({wan:null})
setSelectedGreEdge(null)
setSelectedService(null)
setHighlightedPath(null)
setExpandedCountryId(null)
returnToMapControl()
}
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
if (e.key === "-") applyZoomCenter(1 / 1.25)
if (e.key === "0" || e.key.toLowerCase() === "f") {setPan({x:fitX,y:fitY});setZoom(fitZoom)}
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)
}, [applyZoomCenter,fitX,fitY,fitZoom,setSelected,updateUrl,ctxMenu,showLayers,returnToMapControl])
// ── Drag ─────────────────────────────────────────────────────────────────
function onSvgPointerDown(e: React.PointerEvent) {
if(!e.isPrimary||e.button!==0)return
if(e.target!==e.currentTarget&&!(e.target instanceof Element&&e.target.hasAttribute("data-map-background")))return
e.currentTarget.setPointerCapture(e.pointerId)
suppressClickRef.current=false
// 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 onSvgPointerMove(e: React.PointerEvent) {
if(!e.isPrimary)return
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 * (viewportHeight / 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 * (viewportHeight / 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 onSvgPointerUp(e:React.PointerEvent) {
if(!e.isPrimary||e.button!==0)return
if (finishNodeDrag()) return
if(!dragRef.current)return
const moved = dragRef.current?.moved ?? false
dragRef.current = null
setIsDragging(false)
if (!moved) {
setSelected(null)
setSelWanIdx(null)
setSelectedGreEdge(null)
setSelectedService(null)
setHighlightedPath(null)
setExpandedCountryId(null)
}
}
// ── Node drag start ──────────────────────────────────────────────────────
function onNodePointerDown(e: React.PointerEvent, n: Server & {x: number; y: number}) {
if(!e.isPrimary||e.button!==0)return
e.currentTarget.setPointerCapture(e.pointerId)
suppressClickRef.current=false
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 onSatPointerDown(e: React.PointerEvent, homeId: string, wanIdx: number, x: number, y: number) {
if(!e.isPrimary||e.button!==0)return
e.currentTarget.setPointerCapture(e.pointerId)
suppressClickRef.current=false
nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
setDraggedSatKey(`${homeId}-${wanIdx}`)
}
function onServicePointerDown(e: React.PointerEvent, svcId: string, x: number, y: number) {
if(!e.isPrimary||e.button!==0)return
e.currentTarget.setPointerCapture(e.pointerId)
suppressClickRef.current=false
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" || filter === "unknown") return n.status === filter
return n.type === filter
}
// ── Side panel ────────────────────────────────────────────────────────────
useEffect(() => {
if (
expandedCountryId
&& (!mapCountries.some((s) => s.id === expandedCountryId)
|| !mapCountryServiceGroups.some((g) => g.countryId === expandedCountryId))
) {
queueMicrotask(() => setExpandedCountryId(null))
}
}, [mapCountries, mapCountryServiceGroups, expandedCountryId])
useEffect(() => {
if (!selectedService) return
const stillVisible =
destNodes.some((s) => s.id === selectedService.id)
|| nestedServices.some((s) => s.id === selectedService.id)
if (!stillVisible) {
queueMicrotask(() => {
setSelectedService(null)
setHighlightedPath(null)
})
}
}, [destMode, destNodes, nestedServices, selectedService])
useEffect(() => {
if (!showServices) queueMicrotask(() => setExpandedCountryId(null))
}, [showServices])
// Сдвиг колонки стран меняет систему координат: сбрасываем drag-овчины сервисов.
useEffect(() => {
queueMicrotask(() => setServicePositions({}))
}, [expandedCountryId])
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)
// Клик по стране в режиме «Страны» раскрывает столбец её сервисов; повторный — сворачивает.
if (destMode === "countries" && svc.id.startsWith("cc:") && !svc.id.includes("|")) {
setExpandedCountryId((prev) => (prev === svc.id ? null : svc.id))
}
}
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.SyntheticEvent, 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 = useMemo(()=>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)
)
})
: [],[selected,mapGreTunnels,mapServers,greResolvedMap])
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: "unknown", label: "Неизвестно" },
{ value: "jump-host", label: "JumpHost" },
{ value: "exit-node", label: "Exit Node" },
{ value: "home-router", label: "Home Router"},
]
// ═════════════════════════════════════════════════════════════════════════
return (
{useLiveData && (
)}
>
}
/>
{urlState.server&&((hasSnapshot||!useLiveData)&&!selected||selected&&selWanIdx!==null&&!selected.wanUplinks?.[selWanIdx])&&Объект карты недоступен{!selected?"Устройство "+urlState.server+" отсутствует в текущем снимке.":"Запрошенный WAN отсутствует у выбранного устройства."}}
{/* Filter pills */}
{filterBtns.map(b => (
))}
{/* Search */}
setSearch(e.target.value)}
aria-label="Поиск узла" maxLength={256} 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 && (
)}
{/* Dest overlay: services vs countries */}
{([
{ value: "services" as const, label: "Сервисы" },
{ value: "countries" as const, label: "Страны" },
]).map((b) => (
))}
{/* Layers dropdown */}
Слои
} />
{([
{ 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 => (
))}
{useLiveData&&!flowReadAt?"Порог доли неизвестен":mapSharePct > 0
? `Порог доли ≥ ${mapSharePct}% · Настройки → NetFlow`
: "Порог доли выключен (все узлы, макс. 20) · Настройки → NetFlow"}
{destMode === "countries" && (!useLiveData||flowReadAt) && !mapCountryLoaded && (
GeoIP Country не загружен, страны из RIPE-кэша
)}
{(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0 || Object.keys(servicePositions).length > 0) && (
)}
{/* Stats */}
{useLiveData ? (readAt ? "Снимок API · ответ "+new Date(readAt).toLocaleTimeString("ru-RU")+" (время браузера)" : "Данные карты неизвестны") : "Демо · mock"}
{useLiveData&&{showNetflow||showServices?(flowReadAt?"NetFlow · 5 мин · ответ "+new Date(flowReadAt).toLocaleTimeString("ru-RU")+" (время браузера) · "+(flowListener?"приёмник включён":"приёмник выключен"):"Данные NetFlow неизвестны"):"NetFlow: отображение и обновление выключены"}}
{useLiveData&&flowLoading&&(showNetflow||showServices)&&Обновление NetFlow…}
{useLiveData&&flowError&&(showNetflow||showServices)&&NetFlow: {flowError}. {flowReadAt?"Сохранён предыдущий снимок NetFlow; актуальность не подтверждена.":"Первоначальная загрузка NetFlow не подтверждена."}}
{useLiveData&&dnsError&&DNS: {dnsError}. Связи по неизвестному адресу не подтверждены.}
Пунктир WAN→JH — модель связности; маршрут не подтверждён.
{dataLoading && Обновление снимка карты…}
{dataError && (
{dataError}. {hasSnapshot ? "Последний полный снимок; актуальность не подтверждена." : "Первоначальная загрузка карты не подтверждена."}
)}
{useLiveData && !hasSnapshot ? "—" : mapServers.filter(s => s.status === "online").length} онлайн
{useLiveData && !hasSnapshot ? "—" : mapServers.filter(s => s.status === "degraded").length} с проблемами
{useLiveData && !hasSnapshot ? "—" : mapServers.filter(s => s.status === "offline").length} оффлайн
{useLiveData && !hasSnapshot ? "—" : mapServers.filter(s => s.status === "unknown").length} неизвестно
{greEdges.length}
/
{mapGreTunnels.length}
{" "}GRE
{/* ── Main area ── */}
{/* ── Canvas ── */}
Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.
{useLiveData&&!hasSnapshot&&dataLoading&&
Загружаем карту…
}
{(hasSnapshot||!useLiveData)&&!nodes.length&&
Устройств на карте пока нет
В текущем источнике нет устройств для построения схемы.
Открыть серверы
}
{!!nodes.length&&!nodes.some(isVisible)&&
Узлы по заданным условиям не найдены.
}
{/* ── 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,!useLiveData)
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)" : useLiveData?"Ping неизвестен":"Модель RTT (демо)"}
{merged.pingMs == null ? "—" : `${merged.pingMs} мс`}
{bwMon ? "TX / RX (BT)" : useLiveData?"TX / RX неизвестны":"Скорость канала (демо)"}
{(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 и интерфейсам. "
: useLiveData?"Измерения GRE неизвестны: нет подходящей пробы. ":"Измерения демонстрационные. "}
{spGr?"Проба "+spGr.id+" · время Ping: "+(spGr.lastPingAt?new Date(spGr.lastPingAt).toLocaleString("ru-RU"):"неизвестно")+" · время TX/RX неизвестно. ":""}
Поток — снимок NetFlow за 5 мин (как в «Трафик»: без overlay/mesh), не ёмкость канала.
Статус туннеля
{t.status}
На узле
{edge.fromServer.name}
Пир (каталог)
{edge.toServer.name}
Включён
{t.enabled ? "да" : "нет"}
IPsec
{t.ipsec ? (
<> да>
) : (
<> {useLiveData?"неизвестно":"нет (демо)"}>
)}
Адреса и параметры
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-туннелей
>
)
})()}
>
) : selectedNestedService ? (
<>
{selectedNestedService.label}
{`Сервис в ${destDisplayLabel(expandedCountry ?? undefined, "countries")} · ${selectedNestedService.category}`}
Доля страны
{serviceSharePct(selectedNestedService.share)}
{mapTotalBytes > 0 && (
Доля окна
{serviceSharePct(selectedNestedService.bytes / mapTotalBytes)}
)}
Скорость
{formatNetflowRate({
bytes: selectedNestedService.bytes,
bps: selectedNestedService.bps,
bpsFwd: selectedNestedService.bps,
bpsRev: 0,
})}
Выход
p.serviceId === selectedNestedService.id)
.slice()
.sort((a, b) => b.bps - a.bps)}
servers={mapServers}
services={nestedServices}
highlight={highlightedPath}
viaMode="via"
destMode="services"
onToggle={togglePathHighlight}
/>
>
) : liveSelectedService ? (
<>
{destMode === "countries" && liveSelectedService.label !== "Прочее" && liveSelectedService.id !== "cc:other"
?
: }
{destDisplayLabel(liveSelectedService, destMode)}
{destMode === "countries"
? `Конечная страна${liveSelectedService.label !== "Прочее" ? ` · ${liveSelectedService.label}` : ""}`
: `Конечный сервис · ${liveSelectedService.category}`}
Доля окна
{serviceSharePct(liveSelectedService.share)}
{mapTotalBytes > 0 && (
Классифицировано
{formatNetflowRate({
bytes: mapNamedBytes,
bps: (mapNamedBytes * 8) / Math.max(1, mapWindowSec),
bpsFwd: 0,
bpsRev: 0,
})}
{" из "}
{formatNetflowRate({
bytes: mapTotalBytes,
bps: (mapTotalBytes * 8) / Math.max(1, mapWindowSec),
bpsFwd: 0,
bpsRev: 0,
})}
)}
{!mapAsnLoaded && (!useLiveData||flowReadAt) && destMode === "services" && (
GeoLite2 ASN
не загружена
)}
{destMode === "countries" && !mapCountryLoaded && (
GeoIP Country
RIPE-кэш
)}
Скорость
{formatNetflowRate({
bytes: liveSelectedService.bytes,
bps: liveSelectedService.bps,
bpsFwd: liveSelectedService.bps,
bpsRev: 0,
})}
{expandedCountryGroup && liveSelectedService.id === expandedCountryGroup.countryId && (
Сервисы в стране
{expandedCountryGroup.services.map((svc) => (
))}
)}
Выход
{visibleServiceEdges.filter((e) => e.toId === liveSelectedService.id).map((e) => {
const src = mapServers.find((s) => s.id === e.fromId)
const enPaths = destPaths
.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], ["Сессий", useLiveData?(sessionCounts[selected.id]==null?"—":String(sessionCounts[selected.id])):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] ?? "var(--muted-foreground)"
const myEdges = wanJhEdges.filter(e => e.homeId === selected.id && e.wanIdx === wIdx)
const isActive = myEdges.some(e => e.active)
const isSel = selWanIdx === wIdx
return (
{if(e.key==="Enter"||e.key===" "){e.preventDefault();setSelWanIdx(prev=>prev===wIdx?null:wIdx)}}}
onClick={() => 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}
{useLiveData?"Аплинк каталога":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 · Ping модели
{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,!useLiveData)
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 ? "Основная на схеме" : "Дополнительная на схеме"}
{t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
→ {peer ? `${peer.name} · ${peer.site}` : t.remoteAddress}
{t.ipsec
? IPsec
: {useLiveData?"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={destNodes}
highlight={highlightedPath}
viaMode="service"
destMode={destMode}
onToggle={togglePathHighlight}
/>
)}
{/* Resources */}
{(() => {
const res = srvResMap[selected.id]
if (!res) return null
const cpuPct = res.cpu
const ramPct = res.ramUsed==null||res.ramTotal==null||res.ramTotal===0?null:Math.round(res.ramUsed/res.ramTotal*100)
const hddPct = res.hddUsed==null||res.hddTotal==null||res.hddTotal===0?null:Math.round(res.hddUsed/res.hddTotal*100)
return (
Ресурсы
{!res.fromPoll && (
Нет снапшота опроса — показаны демо-значения. Включите опрос серверов или откройте «Серверы».
)}
{res.fromPoll && (
Последний poll: {res.measuredAt ? new Date(res.measuredAt).toLocaleString("ru-RU") : "время неизвестно"}. Неизвестные ресурсы — «—». История CPU и диск API не передаются.
)}
{/* CPU with sparkline */}
CPU
{cpuPct==null?"—":cpuPct+"%"}
{cpuPct!=null&&}
{res.cpuHistory.length>1&&}
{/* RAM */}
RAM
{fmtMB(res.ramUsed)} / {fmtMB(res.ramTotal)}
{ramPct!=null&&
}
{/* HDD (нет в GET /api/servers — всегда условная шкала) */}
HDD
{!useLiveData && (
(оценка)
)}
{fmtMB(res.hddUsed)} / {fmtMB(res.hddTotal)}
{hddPct!=null&&
}
{/* 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&¤tCtxServer&&setCtxMenu(null)} />}
)
}