Files
MikrotikManager/app/(main)/network-map/page.tsx
T

3598 lines
177 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState, useRef, useEffect, useMemo, useCallback, Suspense } from "react"
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 {
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: null
mtu: number
keepaliveInterval: number
keepaliveRetries: number
dscp: "inherit" | number
clampTcpMss: boolean
allowFastPath: boolean
comment: string
enabled: boolean
status: "up" | "down" | "degraded"
}
function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel {
return {
id: t.id,
name: t.name,
serverId: String(t.serverId),
localAddress: t.localAddress,
remoteAddress: t.remoteAddress,
localInnerIp: t.localInnerIp,
remoteInnerIp: t.remoteInnerIp,
poolId: t.poolId || "live",
ipsec: null,
mtu: t.mtu,
keepaliveInterval: t.keepaliveInterval,
keepaliveRetries: t.keepaliveRetries,
dscp: t.dscp,
clampTcpMss: t.clampTcpMss,
allowFastPath: t.allowFastPath,
comment: t.comment,
enabled: t.enabled,
status: t.status,
}
}
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
return requestJson<T>(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 (
<div className="h-1.5 flex-1 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full transition-all duration-700", resBarColor(pct))}
style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
/>
</div>
)
}
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 (
<svg width={W} height={H} className="shrink-0">
<polyline points={pts} fill="none" stroke="currentColor" strokeWidth="1.2"
className="text-emerald-500/60" strokeLinejoin="round" strokeLinecap="round" />
</svg>
)
}
// ─── 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<ServerType, { label: string; fill: string; r: number }> = {
// 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<ServerType, string> = {
"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<FlowMapServicePath, "clientId" | "viaId" | "enId" | "serviceId">): 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<string>()
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<string, { x: number; y: number }>,
): 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<string, FlowMapServiceEdge>()
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) {
const label = name.replace(/^(home|mt)-/, "").split("-").slice(0, 3).join("-")
return label.length > 24 ? label.slice(0, 23).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 (
<g transform={`translate(${mx},${my})`}>
<title>
WAN → JumpHost: latency (из каталога) дом + latency JH, без отдельного ping по ребру. Не RTT по GRE.
</title>
<rect x="-30" y={-h / 2} width="60" height={h} rx="4"
fill="var(--background)" stroke={color} strokeWidth="0.7" opacity="0.92" />
<text textAnchor="middle" y={hasSpeed ? "-3" : "4"}
fontSize="8.5" fontWeight="600" fill={color} fontFamily="ui-monospace,monospace">
{ping == null ? "—" : `${ping} мс`}
</text>
{hasSpeed && (
<text textAnchor="middle" y="10" fontSize="7" fill="var(--muted-foreground)" fontFamily="ui-monospace,monospace">
{`↓${dl??"—"} ↑${ul??"—"}`}
</text>
)}
</g>
)
}
/** Метрики на 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<SVGElement>) => 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 (
<g
role="button" aria-label={"Метрики "+(outerSummary??"связи")} tabIndex={0} className="focus-visible:outline-2 focus-visible:outline-ring"
onKeyDown={e=>{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) }}
>
<title>{hint}</title>
<rect
x={-bw / 2}
y={-bh / 2}
width={bw}
height={bh}
rx="6"
fill="var(--popover)"
stroke="var(--status-degraded)"
strokeWidth="1.15"
/>
<text
textAnchor="middle"
y={hasSpeed ? "-5" : "4"}
fontFamily="ui-monospace,monospace"
>
<tspan fill="var(--status-degraded)" fontSize="9.5" fontWeight="700">{v1}</tspan>
</text>
{hasSpeed && (
<text textAnchor="middle" y="11" fontFamily="ui-monospace,monospace">
<tspan fill="var(--chart-4)" fontSize="7.5" fontWeight="600">{`↓${dl??"—"} ↑${ul??"—"}`}</tspan>
</text>
)}
</g>
)
}
/** Живой поток NetFlow (не ёмкость канала / не BT). */
function NetflowRateBadge({
mx,
my,
hop,
onOpen,
}: {
mx: number
my: number
hop: MatchedNetflowHop
onOpen?: (e: React.SyntheticEvent<SVGElement>) => void
}) {
const showDir = hop.bpsFwd > 0 && hop.bpsRev > 0
const bw = showDir ? 86 : 72
const bh = showDir ? 32 : 20
return (
<g
role={onOpen?"button":undefined} aria-label={onOpen?"Поток NetFlow между узлами":undefined} tabIndex={onOpen?0:undefined} className="focus-visible:outline-2 focus-visible:outline-ring"
onKeyDown={e=>{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) }}
>
<title>
Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно.
</title>
<rect
x={-bw / 2}
y={-bh / 2}
width={bw}
height={bh}
rx="6"
fill="var(--popover)"
stroke="var(--status-online)"
strokeWidth="1.15"
/>
<text textAnchor="middle" y={showDir ? "-4" : "4"} fontFamily="ui-monospace,monospace">
<tspan fill="var(--status-online-fg)" fontSize="8" fontWeight="700">{formatNetflowRate(hop)}</tspan>
</text>
{showDir && (
<text textAnchor="middle" y="10" fontFamily="ui-monospace,monospace">
<tspan fill="var(--status-online)" fontSize="6.5" fontWeight="600">{formatNetflowDir(hop)}</tspan>
</text>
)}
</g>
)
}
function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) {
const ss = STATUS_STYLE[n.status]
const ts = TYPE_STYLE[n.type]
const lc = latencyColor(n.latency)
const ox = n.x + ts.r + 14
const oy = n.y - 32
const lines = [
n.name,
`${TYPE_LABELS[n.type]} · ${n.site}`,
n.latency != null ? `${n.latency} мс` : "Офлайн",
n.model ?? "",
].filter(Boolean)
const bw = 148, bh = lines.length * 14 + 14
return (
<g aria-hidden="true" transform={`translate(${ox},${oy})`} style={{ pointerEvents: "none" }}>
<rect x="0" y="0" width={bw} height={bh} rx="6"
fill="var(--popover)" stroke={ss.stroke} strokeWidth="0.8" opacity="0.97" />
{/* colour accent line */}
<rect x="0" y="0" width="3" height={bh} rx="2" fill={ts.fill} />
{lines.map((l, i) => (
<text key={i}
x="10" y={12 + i * 14}
fontSize={i === 0 ? "9.5" : "8"} fontWeight={i === 0 ? "700" : "400"}
fill={i === 2 ? lc : i === 0 ? "var(--foreground)" : "var(--muted-foreground)"}
fontFamily="ui-monospace,monospace">
{l}
</text>
))}
</g>
)
}
function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onMouseDown }: {
n: Server & { x: number; y: number }
isSel: boolean; isVis: boolean; isDragged: boolean
/** Не дублировать «N мс» из каталога, если на карте уже показаны метрики GRE (оранжевые числа в круге). */
hideCatalogLatency?: boolean
onClick: () => void
onDblClick: () => void
onHoverChange: (hov: boolean) => void
onContextMenu: (e: React.MouseEvent) => void
onMouseDown: (e: React.MouseEvent) => void
}) {
const ss = STATUS_STYLE[n.status]
const ts = TYPE_STYLE[n.type]
return (
<g className="focus-visible:outline-2 focus-visible:outline-ring" role="button" aria-label={"Устройство "+n.name} aria-pressed={isSel} tabIndex={isVis?0:-1}
onKeyDown={e=>{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}
onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }}
onClick={e => { e.stopPropagation(); onClick() }}
onDoubleClick={e => { e.stopPropagation(); onDblClick() }}
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); onContextMenu(e) }}
onMouseEnter={() => onHoverChange(true)}
onMouseLeave={() => onHoverChange(false)}
>
{n.status === "online" && isVis && (
<circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
</circle>
)}
{n.status === "degraded" && isVis && (
<circle r={ts.r + 12} fill={ss.glow} />
)}
{isSel && (
<circle r={ts.r + 10} fill="none" stroke="var(--foreground)" strokeWidth="1.5" strokeDasharray="4 3" />
)}
<circle r={ts.r} fill={ss.fill} stroke={ss.stroke} strokeWidth={isSel ? 2.5 : 1.8} />
<g transform={`translate(${ts.r - 10},-${ts.r - 10})`}>
<circle r="10" fill={ts.fill} />
<text textAnchor="middle" y="4" fontSize="6.5" fontWeight="bold" fill="var(--primary-foreground)"
fontFamily="system-ui,sans-serif">{ts.label}</text>
</g>
{n.country?.trim() ? (
<foreignObject
x={-110}
y={-22}
width={220}
height={28}
style={{ overflow: "visible", pointerEvents: "none" }}
>
{/* Внутри foreignObject Tailwind не всегда применяется к тексту — явные стили, без lineHeight:0 (иначе подпись пропадает). */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
width: "100%",
height: "100%",
fontFamily: "ui-monospace, monospace",
}}
{...({ xmlns: "http://www.w3.org/1999/xhtml" } as Record<string, string>)}
>
<span style={{ flexShrink: 0, lineHeight: 0 }}>
<Flag code={n.country.trim()} size={ts.r > 32 ? 18 : 16} />
</span>
<span
style={{
maxWidth: 170,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 700,
fontSize: ts.r > 32 ? 13 : 11,
color: "var(--foreground)",
lineHeight: 1.25,
}}
>
{n.site}
</span>
</div>
</foreignObject>
) : (
<text textAnchor="middle" y="-4" fontSize={ts.r > 32 ? "13" : "11"} fontWeight="700"
fill="var(--foreground)" fontFamily="ui-monospace,monospace">{n.site}</text>
)}
{!hideCatalogLatency && (
<text textAnchor="middle" y="12" fontSize="8.5" fill={latencyColor(n.latency)} fontFamily="ui-monospace,monospace">
{n.latency == null ? "—" : `${n.latency} мс`}
</text>
)}
{(n.name && (
<text textAnchor="middle" y={hideCatalogLatency ? 15 : ts.r + 18} fontSize="8.5" fill="var(--muted-foreground)"
fontFamily="ui-monospace,monospace">{shortName(n.name)}</text>
))}
</g>
)
}
function ServiceNode({
label,
share,
x,
y,
isSel,
isVis,
isDragged,
destMode,
iso,
dim,
shareLabel,
onClick,
onMouseDown,
}: {
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
onMouseDown: (e: React.MouseEvent) => void
}) {
const bw = MAP_SERVICE_NODE_W
const bh = MAP_SERVICE_NODE_H
const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : ""
return (
<g role="button" aria-label={(destMode==="countries"?"Страна ":"Сервис ")+label} aria-pressed={isSel} tabIndex={isVis?0:-1} className="focus-visible:outline-2 focus-visible:outline-ring"
onKeyDown={e=>{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}
onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e) }}
onClick={(e) => { e.stopPropagation(); onClick() }}
>
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
{isSel && (
<rect
x={-bw / 2 - 6}
y={-bh / 2 - 6}
width={bw + 12}
height={bh + 12}
rx="12"
fill="none"
stroke="var(--chart-4)"
strokeWidth="1.5"
strokeDasharray="4 3"
/>
)}
<rect
x={-bw / 2}
y={-bh / 2}
width={bw}
height={bh}
rx="10"
fill="var(--card)"
stroke="var(--chart-4)"
strokeWidth={isSel ? 2.2 : 1.4}
/>
<g transform="translate(-11,-24)" pointerEvents="none">
{flagIso
? <CountryFlagSvg iso={flagIso} size={22} />
: <ServiceBrandIcon aria-hidden="true" label={destMode === "countries" ? "Прочее" : label} size={22} />}
</g>
<text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="var(--foreground)" fontFamily="ui-monospace,monospace">
{label}
</text>
<text textAnchor="middle" y="25" fontSize="7.5" fill="var(--chart-4)" fontFamily="ui-monospace,monospace">
{serviceSharePct(share)}
</text>
</g>
)
}
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 <p className="text-xs text-muted-foreground">—</p>
}
return (
<div className="flex flex-col gap-1">
{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 (
<button type="button"
key={rowKey}
title={title}
onClick={() => onToggle(p)}
className={cn(
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
active ? "bg-cyan-500/15 ring-1 ring-cyan-500/40" : "hover:bg-muted/50",
)}
>
<span className="font-mono truncate min-w-0">{label}</span>
<span className="font-mono text-emerald-400 tabular-nums shrink-0">
{formatNetflowRate({ bytes: p.bytes, bps: p.bps, bpsFwd: p.bps, bpsRev: 0 })}
</span>
</button>
)
})}
</div>
)
}
function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: {
x: number; y: number
wan: { name: string; isp: string; maxDl: number; maxUl: number }
color: string; active: boolean; isSel: boolean; isDragged: boolean
onSelect: () => void
onMouseDown: (e: React.MouseEvent) => void
}) {
const r = 18
return (
<g role="button" aria-label={"Аплинк "+wan.name} aria-pressed={isSel} tabIndex={0} className="focus-visible:outline-2 focus-visible:outline-ring"
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}}
transform={`translate(${x},${y})`}
style={{ cursor: isDragged ? "grabbing" : "grab" }}
onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }}
onClick={e => { e.stopPropagation(); onSelect() }}>
{active && (
<circle r={r + 6} fill={color} opacity="0.12">
</circle>
)}
{isSel && (
<circle r={r + 8} fill="none" stroke={color} strokeWidth="1.2" strokeDasharray="3 2" opacity="0.6" />
)}
<circle r={r} fill={`color-mix(in oklch,${color} 10%,transparent)`} stroke={color}
strokeWidth={active ? 2 : 1.2} opacity={active ? 1 : 0.65} />
<g opacity={active ? 0.9 : 0.5}>
<path d="M -5 2 Q 0 -5 5 2" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
<path d="M -8 5 Q 0 -10 8 5" fill="none" stroke={color} strokeWidth="1" strokeLinecap="round" opacity="0.6" />
<circle cx="0" cy="4" r="2" fill={color} />
</g>
<text textAnchor="middle" y={r + 14} fontSize="8" fontWeight="700"
fill={color} fontFamily="ui-monospace,monospace">{wan.name}</text>
<text textAnchor="middle" y={r + 24} fontSize="7" fill="var(--muted-foreground)" fontFamily="ui-monospace,monospace">
{wan.isp.length > 10 ? wan.isp.slice(0, 9) + "…" : wan.isp}
</text>
<text textAnchor="middle" y={r + 34} fontSize="6.5" fill="var(--muted-foreground)" fontFamily="ui-monospace,monospace">
↓{wan.maxDl} ↑{wan.maxUl}
</text>
</g>
)
}
// ─── Context menu ─────────────────────────────────────────────────────────────
interface CtxMenu { x: number; y: number; server: Server }
function ContextMenu({ menu, onClose }: { menu: CtxMenu; onClose: () => void }) {
const items = [
{
icon: <TerminalIcon aria-hidden="true" className="size-3" />,
label: "Открыть терминал",
href: `/terminal?host=${encodeURIComponent(menu.server.host)}`,
},
{
icon: <ShieldIcon aria-hidden="true" className="size-3" />,
label: "Firewall",
href: `/firewall?server=${menu.server.id}`,
},
{
icon: <CableIcon aria-hidden="true" className="size-3" />,
label: "GRE-туннели",
href: `/gre?server=${menu.server.id}`,
},
{
icon: <ActivityIcon aria-hidden="true" className="size-3" />,
label: "Трафик",
href: `/traffic?server=${menu.server.id}`,
},
]
return (
<Dialog open onOpenChange={open=>{if(!open)onClose()}}>
<DialogContent showCloseButton={false} finalFocus={()=>document.querySelector<HTMLButtonElement>("[data-map-device-actions]")??document.querySelector<HTMLSelectElement>("[data-map-device-select]")}>
<DialogHeader>
<DialogTitle className="[overflow-wrap:anywhere] pr-8">Действия устройства · {menu.server.name}</DialogTitle>
<DialogDescription className="[overflow-wrap:anywhere]">{menu.server.host}</DialogDescription>
</DialogHeader>
<StatusBadge status={menu.server.status} />
<nav aria-label="Разделы устройства" className="flex flex-col gap-1">
{items.map(item=><a key={item.label} href={item.href} onClick={onClose} className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring"><span aria-hidden="true">{item.icon}</span>{item.label}</a>)}
</nav>
<Button type="button" variant="outline" onClick={async()=>{try{await navigator.clipboard.writeText(menu.server.host);toast.success("Адрес устройства скопирован");onClose()}catch{toast.error("Не удалось скопировать адрес")}}}>Скопировать адрес</Button>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Закрыть действия устройства" className="absolute top-2 right-2" onClick={onClose}><XIcon aria-hidden="true" /></Button>
</DialogContent>
</Dialog>
)
}
// ─── Minimap ──────────────────────────────────────────────────────────────────
const MM_W = 172, MM_H = 94
function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, servicePos, onClose, onPan }: {
pan: { x: number; y: number }; zoom: number
nodes: (Server & { x: number; y: number })[]
greEdges: GreMapEdge[]
satPos: Record<string, { x: number; y: number }[]>
wanJhEdges: WanJhEdge[]
homeRouters: Server[]
servicePos: Record<string, { x: number; y: number }>
onClose: () => void
onPan: (x: number, y: number) => void
}) {
const vpW = Math.min(W, W / zoom)
const vpH = Math.min(H, H / zoom)
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
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 (
<div className="hidden lg:block absolute bottom-16 right-4 z-20 rounded-lg overflow-hidden
border border-border bg-popover/95 backdrop-blur-sm shadow-xl"
style={{ width: MM_W, height: MM_H }}>
<svg width={MM_W} height={MM_H} viewBox={`0 0 ${W} ${H}`}
style={{ display: "block", cursor: "crosshair" }} onClick={handleClick}>
<rect width={W} height={H} fill="var(--background)" />
{/* simplified edges */}
{greEdges.map((e) => (
<line key={greEdgeKey(e)} x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
stroke={TUNNEL_STYLE[e.tunnel.status].stroke} strokeWidth="5" opacity="0.25" />
))}
{/* WAN edges */}
{wanJhEdges.map((edge) => {
const sat = satPos[edge.homeId]?.[edge.wanIdx]
const jh = nodes.find(n => n.id === edge.jhId)
if (!sat || !jh) return null
const edgeKey = `${edge.homeId}-${edge.wanIdx}-${edge.jhId}`
return <line key={edgeKey} x1={sat.x} y1={sat.y} x2={jh.x} y2={jh.y}
stroke={WAN_COLORS[edge.wanIdx]} strokeWidth="3" opacity="0.25" />
})}
{/* nodes */}
{nodes.map(n => (
<circle key={n.id} cx={n.x} cy={n.y}
r={TYPE_STYLE[n.type].r * 0.65}
fill={TYPE_STYLE[n.type].fill}
stroke={STATUS_STYLE[n.status].stroke}
strokeWidth="3"
opacity="0.85"
/>
))}
{/* WAN satellites */}
{homeRouters.flatMap(r =>
(satPos[r.id] ?? []).map((p, i) => (
<circle key={`${r.id}-${i}`} cx={p.x} cy={p.y} r="10"
fill={`color-mix(in oklch,${WAN_COLORS[i]} 20%,transparent)`} stroke={WAN_COLORS[i]} strokeWidth="3" opacity="0.7" />
))
)}
{Object.entries(servicePos).map(([id, p]) => (
<rect key={id} x={p.x - 14} y={p.y - 10} width="28" height="20" rx="4"
fill="var(--card)" stroke="var(--chart-4)" strokeWidth="3" opacity="0.85" />
))}
{/* viewport rect */}
<rect x={pan.x} y={pan.y} width={W / zoom} height={H / zoom}
fill="var(--muted)" fillOpacity="0.1" stroke="var(--foreground)" strokeOpacity="0.6" strokeWidth="6" rx="6" />
</svg>
<button type="button"
aria-label="Закрыть миникарту" onClick={e => { e.stopPropagation(); onClose() }}
className="absolute top-1 right-1 text-muted-foreground hover:text-foreground transition-colors leading-none">
<XIcon aria-hidden="true" className="size-2.5" />
</button>
<span className="absolute bottom-1 left-2 text-[7px] font-mono text-muted-foreground pointer-events-none">minimap</span>
</div>
)
}
// ─── Zoom controls overlay ────────────────────────────────────────────────────
function ZoomControls({ zoom, onZoomIn, onZoomOut, onFit }: {
zoom: number
onZoomIn: () => void
onZoomOut: () => void
onFit: () => void
}) {
return (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-0
rounded-xl border border-border bg-popover/95 backdrop-blur-md shadow-xl overflow-hidden">
<button type="button" onClick={onZoomOut}
className="px-3 py-2 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Уменьшить карту" title="Уменьшить (-)">
<ZoomOutIcon aria-hidden="true" className="size-3.5" />
</button>
<span className="px-3 py-2 text-xs font-mono text-muted-foreground min-w-[52px] text-center border-x border-border select-none">
{Math.round(zoom * 100)}%
</span>
<button type="button" onClick={onZoomIn}
className="px-3 py-2 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Увеличить карту" title="Увеличить (+)">
<ZoomInIcon aria-hidden="true" className="size-3.5" />
</button>
<div className="w-px h-5 bg-border" />
<button type="button" onClick={onFit}
className="px-3 py-2 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Вписать карту" title="Вписать (F / 0)">
<Maximize2Icon aria-hidden="true" className="size-3.5" />
</button>
</div>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function NetworkMapPage() {
return <Suspense fallback={<p role="status">Загрузка контекста карты…</p>}><NetworkMapRoute /></Suspense>
}
function NetworkMapRoute(){
const {mode,backendUrl}=useDataSource()
const params=useSearchParams()
const state=readNetworkMapUrl(new URLSearchParams(params.toString()))
if(state.error)return <div><PageHeader crumbs={[{label:"Обзор"},{label:"Карта сети"}]} /><Alert variant="destructive"><AlertTitle>Контекст карты недоступен</AlertTitle><AlertDescription>{state.error}<Button type="button" variant="outline" onClick={()=>window.history.replaceState(null,"",patchNetworkMapUrl(new URLSearchParams(window.location.search),{server:null,q:null,filter:null,dest:null,wan:null})+window.location.hash)}>Сбросить контекст карты</Button></AlertDescription></Alert></div>
return <NetworkMapContent key={JSON.stringify([mode,backendUrl])} />
}
function NetworkMapContent() {
const params=useSearchParams()
const serializedParams=params.toString()
const urlState=useMemo(()=>readNetworkMapUrl(new URLSearchParams(serializedParams)),[serializedParams])
const updateUrl=useCallback((values:Record<string,string|null>,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<Server[]>([])
const [sessionCounts,setSessionCounts]=useState<Record<string,number|null>>({})
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
const [mapServices, setMapServices] = useState<FlowMapService[]>([])
const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
const [mapServicePaths, setMapServicePaths] = useState<FlowMapServicePath[]>([])
const [mapCountries, setMapCountries] = useState<FlowMapService[]>([])
const [mapCountryEdges, setMapCountryEdges] = useState<FlowMapServiceEdge[]>([])
const [mapCountryPaths, setMapCountryPaths] = useState<FlowMapServicePath[]>([])
const [mapCountryServiceGroups, setMapCountryServiceGroups] = useState<FlowMapCountryServiceGroup[]>([])
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<Record<string, string>>({})
const [flowError,setFlowError]=useState<string|null>(null)
const [flowReadAt,setFlowReadAt]=useState<string|null>(null)
const [flowListener,setFlowListener]=useState(false)
const [flowLoading,setFlowLoading]=useState(false)
const [flowRefresh,setFlowRefresh]=useState(0)
const [dnsError,setDnsError]=useState<string|null>(null)
const [dataError, setDataError] = useState<string | null>(null)
const [dataLoading, setDataLoading] = useState(false)
const [hasSnapshot,setHasSnapshot]=useState(false)
const [readAt,setReadAt]=useState<string|null>(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<string, string>,
[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<unknown>("/api/servers"),
apiFetch<unknown>("/api/filters/gre-tunnels"),
apiFetch<unknown>("/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<typeof validateNetworkMapSnapshot>
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<string>(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<unknown>(
"/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 [isDragging, setIsDragging] = useState(false)
// ── Interaction ─────────────────────────────────────────────────────────────
const selected=urlState.server?mapServers.find(server=>server.id===urlState.server)??null:null
const setSelected=useCallback((value:React.SetStateAction<Server|null>)=>{
const next=typeof value==="function"?value(selected):value
updateUrl({server:next?.id??null,wan:null})
},[selected,updateUrl])
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
/** Раскрытая страна (режим «Страны»): справа столбец её сервисов. Не персистится. */
const [expandedCountryId, setExpandedCountryId] = useState<string | null>(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<number|null>){const next=typeof value==="function"?value(selWanIdx):value;updateUrl({wan:next==null?null:String(next)})}
const [hoveredId, setHoveredId] = useState<string | null>(null)
// ── Node positions (overrides POS defaults) ─────────────────────────────────
const [nodePositions, setNodePositions] = useState<Record<string, { x: number; y: number }>>({})
const [satPositions, setSatPositions] = useState<Record<string, { x: number; y: number }[]>>({})
const [servicePositions, setServicePositions] = useState<Record<string, { x: number; y: number }>>({})
/** После обновления алгоритма раскладки (см. 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<CtxMenu | null>(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<GreMapEdge | null>(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<unknown>("/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<string, { x: number; y: number }[]> = {}
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<string>()
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<string>()
const s = new Set<string>()
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<string>()
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<string>()
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<string, string>()
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<string, number>()
const out = new Map<string, number>()
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<string, MatchedNetflowHop>()
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<string, MatchedNetflowHop>()
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<SVGSVGElement>(null)
// Canvas pan drag
const dragRef = useRef<{
startX: number; startY: number
panX: number; panY: number
moved: boolean
} | null>(null)
// Node / WAN-sat drag
type NodeDrag =
| { kind: "server"; nodeId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean }
| { kind: "wan-sat"; homeId: string; wanIdx: number; startX: number; startY: number; origX: number; origY: number; moved: boolean }
| { kind: "service"; svcId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean }
const nodeDragRef = useRef<NodeDrag | null>(null)
const suppressClickRef = useRef(false)
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
const [draggedSatKey, setDraggedSatKey] = useState<string | null>(null) // `${homeId}-${wanIdx}`
const [draggedSvcId, setDraggedSvcId] = useState<string | null>(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.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(
[`<?xml version="1.0" encoding="UTF-8"?>\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 * (H / z)
// trackpad pinch sends ctrlKey, use smaller step
const step = e.ctrlKey ? 1.06 : 1.14
const factor = e.deltaY < 0 ? step : 1 / step
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z * factor))
setPan({
x: svgX - (e.clientX - rect.left) / rect.width * (W / newZoom),
y: svgY - (e.clientY - rect.top) / rect.height * (H / newZoom),
})
setZoom(newZoom)
}
svg.addEventListener("wheel", handler, { passive: false })
return () => svg.removeEventListener("wheel", handler)
}, [])
// ── 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 + (H / zoom - H / newZoom) / 2,
}))
setZoom(newZoom)
},[zoom])
const fitView=useCallback(() => {
setPan({ x: 0, y: 0 })
setZoom(1)
},[])
function focusNode(x: number, y: number) {
const newZoom = Math.min(ZOOM_MAX, Math.max(zoom * 1.8, 2))
setPan({ x: x - W / (2 * newZoom), y: y - H / (2 * newZoom) })
setZoom(newZoom)
}
// ── 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)
}
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
if (e.key === "-") applyZoomCenter(1 / 1.25)
if (e.key === "0" || e.key.toLowerCase() === "f") fitView()
if (e.key.toLowerCase() === "m") setShowMinimap(v => !v)
if (e.key.toLowerCase() === "p") setShowPingBadges(v => !v)
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [applyZoomCenter,fitView,setSelected,updateUrl,ctxMenu,showLayers])
// ── Drag ─────────────────────────────────────────────────────────────────
function onSvgMouseDown(e: React.MouseEvent<SVGSVGElement>) {
// Only pan on background clicks (node mousedown stops propagation)
dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y, moved: false }
setIsDragging(true)
}
function onSvgMouseMove(e: React.MouseEvent<SVGSVGElement>) {
const nd = nodeDragRef.current
if (nd) {
const dx = e.clientX - nd.startX
const dy = e.clientY - nd.startY
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) nd.moved = true
const rect = e.currentTarget.getBoundingClientRect()
const svgDx = dx * (W / zoom) / rect.width
const svgDy = dy * (H / zoom) / rect.height
const nx = nd.origX + svgDx
const ny = nd.origY + svgDy
if (nd.kind === "server") {
setNodePositions(prev => ({ ...prev, [nd.nodeId]: { x: nx, y: ny } }))
} else if (nd.kind === "service") {
setServicePositions(prev => ({ ...prev, [nd.svcId]: { x: nx, y: ny } }))
} else {
setSatPositions(prev => {
const arr = [...(prev[nd.homeId] ?? (autoLayout.wanSatPos[nd.homeId] ?? []))]
arr[nd.wanIdx] = { x: nx, y: ny }
return { ...prev, [nd.homeId]: arr }
})
}
return
}
if (!dragRef.current) return
const dx = e.clientX - dragRef.current.startX
const dy = e.clientY - dragRef.current.startY
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragRef.current.moved = true
const rect = e.currentTarget.getBoundingClientRect()
setPan({
x: dragRef.current.panX - dx * (W / zoom / rect.width),
y: dragRef.current.panY - dy * (H / zoom / rect.height),
})
}
function finishNodeDrag() {
if (!nodeDragRef.current) return false
suppressClickRef.current = nodeDragRef.current.moved
nodeDragRef.current = null
setDraggedNodeId(null)
setDraggedSatKey(null)
setDraggedSvcId(null)
return true
}
function onSvgMouseUp() {
if (finishNodeDrag()) return
const moved = dragRef.current?.moved ?? false
dragRef.current = null
setIsDragging(false)
if (!moved) {
setSelected(null)
setSelWanIdx(null)
setSelectedGreEdge(null)
setSelectedService(null)
setHighlightedPath(null)
setExpandedCountryId(null)
}
}
// ── Node drag start ──────────────────────────────────────────────────────
function onNodeMouseDown(e: React.MouseEvent, n: Server & { x: number; y: number }) {
nodeDragRef.current = { kind: "server", nodeId: n.id, startX: e.clientX, startY: e.clientY, origX: n.x, origY: n.y, moved: false }
setDraggedNodeId(n.id)
}
function onSatMouseDown(e: React.MouseEvent, homeId: string, wanIdx: number, x: number, y: number) {
nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
setDraggedSatKey(`${homeId}-${wanIdx}`)
}
function onServiceMouseDown(e: React.MouseEvent, svcId: string, x: number, y: number) {
nodeDragRef.current = { kind: "service", svcId, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
setDraggedSvcId(svcId)
}
// ── Visibility / search ──────────────────────────────────────────────────
const sq = search.toLowerCase().trim()
const matchesSearch = (n: typeof nodes[0]) =>
!sq ||
n.name.toLowerCase().includes(sq) ||
n.site.toLowerCase().includes(sq) ||
n.model?.toLowerCase().includes(sq) ||
n.os?.toLowerCase().includes(sq)
const isVisible = (n: typeof nodes[0]) => {
if (!matchesSearch(n)) return false
if (filter === "all") return true
if (filter === "online" || filter === "degraded" || filter === "offline" || 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<SVGElement>, 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<string, string>()
const group = new Map<string, GreTunnel[]>()
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<string, string>()
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 (
<div className="flex flex-col h-full min-h-0 min-w-0 [&_button]:focus-visible:outline-2 [&_button]:focus-visible:outline-ring [&_button]:focus-visible:outline-offset-2">
<PageHeader
crumbs={[{ label: "Обзор" }, { label: "Карта сети" }]}
actions={
<>
{useLiveData && (
<Button type="button" variant="outline" size="sm" onClick={() => {void loadLive();setFlowRefresh(value=>value+1)}} disabled={dataLoading}>
<RefreshCwIcon aria-hidden="true" className={cn("size-4", dataLoading && "animate-spin")} />
Обновить
</Button>
)}
<Button type="button" variant="outline" size="sm" onClick={exportSvg}>
<DownloadIcon aria-hidden="true" className="size-4" />Экспорт SVG
</Button>
</>
}
/>
{urlState.server&&((hasSnapshot||!useLiveData)&&!selected||selected&&selWanIdx!==null&&!selected.wanUplinks?.[selWanIdx])&&<Alert variant="destructive"><AlertTitle>Объект карты недоступен</AlertTitle><AlertDescription>{!selected?"Устройство "+urlState.server+" отсутствует в текущем снимке.":"Запрошенный WAN отсутствует у выбранного устройства."}<Button type="button" variant="outline" onClick={()=>updateUrl({server:null,wan:null})}>Сбросить выбранный объект</Button></AlertDescription></Alert>}
<Frame spacing="sm" className="mx-2 mb-2 min-h-0 min-w-0 flex-1">
<FramePanel className="flex items-center gap-2 p-2 grow-0 shrink-0 flex-wrap">
{/* Filter pills */}
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5 max-w-full overflow-x-auto">
{filterBtns.map(b => (
<button type="button" key={b.value} aria-pressed={filter===b.value} onClick={() => setFilter(b.value)}
className={cn(
"px-2.5 py-1 text-xs rounded transition-colors whitespace-nowrap",
filter === b.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}>
{b.label}
</button>
))}
</div>
{/* Search */}
<div className="relative">
<SearchIcon aria-hidden="true" className="absolute left-2 top-1/2 -translate-y-1/2 size-3 text-muted-foreground pointer-events-none" />
<input
value={search}
onChange={e => 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 && (
<button type="button" aria-label="Очистить поиск узла" onClick={() => setSearch("")}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon aria-hidden="true" className="size-3" />
</button>
)}
</div>
<select data-map-device-select aria-label="Устройство на карте" className="h-8 w-full md:w-auto min-w-0 max-w-full flex-none md:flex-1 rounded-md border border-border bg-background px-2 text-xs focus-visible:outline-2 focus-visible:outline-ring" value={selected?.id??""} disabled={useLiveData&&!hasSnapshot} onChange={e=>{setSelectedGreEdge(null);setSelectedService(null);setSelected(mapServers.find(server=>server.id===e.target.value)??null)}}>
<option value="">Выбрать устройство</option>{mapServers.map(server=><option key={server.id} value={server.id}>{server.name}</option>)}
</select>
{/* Dest overlay: services vs countries */}
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5">
{([
{ value: "services" as const, label: "Сервисы" },
{ value: "countries" as const, label: "Страны" },
]).map((b) => (
<button type="button"
key={b.value} aria-pressed={destMode===b.value}
onClick={() => setDestMode(b.value)}
className={cn(
"px-2.5 py-1 text-xs rounded transition-colors whitespace-nowrap",
destMode === b.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{b.label}
</button>
))}
</div>
{/* Layers dropdown */}
<Popover modal open={showLayers} onOpenChange={setShowLayers}>
<PopoverTrigger render={<button type="button"
className={cn(
"flex items-center gap-1.5 h-7 px-2.5 text-xs rounded-md border transition-colors",
showLayers
? "bg-muted text-foreground border-border"
: "border-border/60 text-muted-foreground hover:text-foreground hover:border-border",
)}>
<LayersIcon aria-hidden="true" className="size-3" />Слои
</button>} />
<PopoverContent align="start" aria-label="Слои карты" className="gap-0.5">
{([
{ 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 => (
<button type="button" key={item.key} aria-pressed={item.val}
onClick={() => item.set((v: boolean) => !v)}
className={cn(
"flex items-center justify-between px-3 py-1.5 rounded-md text-xs transition-colors",
item.val ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}>
<span>{item.label}</span>
<span className="flex items-center gap-2">
{item.hint && (
<kbd className="text-[9px] font-mono px-1 py-0.5 rounded border border-border bg-muted/40">
{item.hint}
</kbd>
)}
<span className={cn(
"size-2 rounded-full",
item.val ? "bg-emerald-400" : "bg-muted-foreground/30",
)} />
</span>
</button>
))}
<p className="px-3 pt-1.5 pb-1 text-[10px] text-muted-foreground leading-snug">
{useLiveData&&!flowReadAt?"Порог доли неизвестен":mapSharePct > 0
? `Порог доли ≥ ${mapSharePct}% · Настройки → NetFlow`
: "Порог доли выключен (все узлы, макс. 20) · Настройки → NetFlow"}
</p>
{destMode === "countries" && (!useLiveData||flowReadAt) && !mapCountryLoaded && (
<p className="px-3 pb-1 text-[10px] text-amber-500 leading-snug">
GeoIP Country не загружен, страны из RIPE-кэша
</p>
)}
{(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0 || Object.keys(servicePositions).length > 0) && (
<div className="border-t border-border/50 mt-1 pt-1">
<button type="button"
onClick={() => { setNodePositions({}); setSatPositions({}); setServicePositions({}) }}
className="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-xs
text-amber-400 hover:bg-amber-500/10 transition-colors">
↺ Сбросить расположение
</button>
</div>
)}
</PopoverContent>
</Popover>
{/* Stats */}
<div className="flex w-full min-w-0 items-center gap-x-3 gap-y-1 text-xs text-muted-foreground flex-wrap">
<span className="text-[10px] border border-border rounded px-1.5 py-0.5">
{useLiveData ? (readAt ? "Снимок API · ответ "+new Date(readAt).toLocaleTimeString("ru-RU")+" (время браузера)" : "Данные карты неизвестны") : "Демо · mock"}
</span>
{useLiveData&&<span>{showNetflow||showServices?(flowReadAt?"NetFlow · 5 мин · ответ "+new Date(flowReadAt).toLocaleTimeString("ru-RU")+" (время браузера) · "+(flowListener?"приёмник включён":"приёмник выключен"):"Данные NetFlow неизвестны"):"NetFlow: отображение и обновление выключены"}</span>}
{useLiveData&&flowLoading&&(showNetflow||showServices)&&<span role="status">Обновление NetFlow…</span>}
{useLiveData&&flowError&&(showNetflow||showServices)&&<span role="alert" className="text-destructive [overflow-wrap:anywhere]">NetFlow: {flowError}. {flowReadAt?"Сохранён предыдущий снимок NetFlow; актуальность не подтверждена.":"Первоначальная загрузка NetFlow не подтверждена."}</span>}
{useLiveData&&dnsError&&<span role="alert" className="text-destructive [overflow-wrap:anywhere]">DNS: {dnsError}. Связи по неизвестному адресу не подтверждены.</span>}
<span>Пунктир WAN→JH — модель связности; маршрут не подтверждён.</span>
{dataLoading && <span role="status">Обновление снимка карты…</span>}
{dataError && (
<span role="alert" className="text-[10px] text-destructive [overflow-wrap:anywhere]">
{dataError}. {hasSnapshot ? "Последний полный снимок; актуальность не подтверждена." : "Первоначальная загрузка карты не подтверждена."}
</span>
)}
<span>
<span className="font-semibold text-emerald-400">{useLiveData && !hasSnapshot ? "—" : mapServers.filter(s => s.status === "online").length}</span> онлайн
</span>
<span>
<span className="font-semibold text-amber-400">{useLiveData && !hasSnapshot ? "—" : mapServers.filter(s => s.status === "degraded").length}</span> с проблемами
</span>
<span>
<span className="font-semibold text-red-400">{useLiveData && !hasSnapshot ? "—" : mapServers.filter(s => s.status === "offline").length}</span> оффлайн
</span>
<span><span className="font-semibold text-muted-foreground">{useLiveData && !hasSnapshot ? "—" : mapServers.filter(s => s.status === "unknown").length}</span> неизвестно</span>
<span title="Линии на карте / всего туннелей в данных">
<span className="font-semibold text-cyan-400">{greEdges.length}</span>
<span className="text-muted-foreground">/</span>
<span className="font-semibold text-foreground">{mapGreTunnels.length}</span>
{" "}GRE
</span>
</div>
</FramePanel>
{/* ── Main area ── */}
<FramePanel className="flex flex-1 min-h-0 min-w-0 p-0 flex-col lg:flex-row">
{/* ── Canvas ── */}
<div className={cn("flex-1 min-h-0 min-w-0 relative overflow-hidden bg-background select-none", (selected || selectedGreEdge || selectedService) && "hidden lg:block")}>
<svg
ref={svgRef} role="group" aria-label="Интерактивная карта сети"
viewBox={`${pan.x} ${pan.y} ${W / zoom} ${H / zoom}`}
style={{ width: "100%", height: "100%", display: "block", cursor: isDragging ? "grabbing" : "grab", userSelect: "none", WebkitUserSelect: "none" }}
onMouseDown={e => { e.preventDefault(); onSvgMouseDown(e) }}
onMouseMove={onSvgMouseMove}
onMouseUp={onSvgMouseUp}
onMouseLeave={() => {
finishNodeDrag()
dragRef.current = null; setIsDragging(false); setHoveredId(null)
}}
>
<defs>
<pattern id="map-dots" width="22" height="22" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.8" fill="var(--border)" />
</pattern>
{WAN_COLORS.map((_, i) => (
<filter key={i} id={`glow-wan-${i}`} x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
))}
</defs>
{/* Background grid */}
<rect width={W} height={H} fill="url(#map-dots)" />
{/* ── GRE edges ── */}
{greEdges.map((e, i) => {
const edgeId = greEdgeKey(e)
const ts = TUNNEL_STYLE[e.tunnel.status]
const fromN = nodeById[e.fromServer.id]
const toN = nodeById[e.toServer.id]
const dimmed =
filter !== "all" &&
fromN && toN &&
!isVisible(fromN) &&
!isVisible(toN)
const baseProbe = greTunnelProbe(e.tunnel,!useLiveData)
const spGre = speedProbeByTunnelId.get(edgeId)
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
const rttFromMonitor = spGre?.lastPingRttMs != null
const throughputFromMonitor =
spGre?.lastTxAvgMbps != null || spGre?.lastRxAvgMbps != null
const badgeLane = greBadgeStaggerByTunnelId.get(edgeId) ?? 0
const tBadge = Math.min(0.78, Math.max(0.22, 0.34 + badgeLane * 0.052))
const normalPx =
(badgeLane % 2 === 0 ? 1 : -1) * (16 + badgeLane * 22)
const { mx, my } = edgeBadgePosition(
e.from.x,
e.from.y,
e.to.x,
e.to.y,
tBadge,
normalPx,
)
const flowHop = netflowByGreKey.get(edgeId)
const flowPos = edgeBadgePosition(
e.from.x,
e.from.y,
e.to.x,
e.to.y,
tBadge,
-normalPx - (normalPx === 0 ? 22 : 0),
)
function openGreDetail(ev: React.SyntheticEvent<SVGElement>) {
ev.stopPropagation()
setSelectedGreEdge(e)
setSelected(null)
setSelectedService(null)
setHighlightedPath(null)
setSelWanIdx(null)
}
return (
<g
key={edgeId}
opacity={dimmed ? 0.05 : (highlightedPath && !greMatchesPath(e, highlightedPath) ? 0.14 : 1)}
style={{ transition: "opacity 0.3s" }}
>
<line
x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
stroke={ts.stroke}
strokeWidth={
highlightedPath && greMatchesPath(e, highlightedPath)
? 3.4
: hopHasRate(flowHop) ? 2.6 : 1.5
}
strokeDasharray={e.tunnel.ipsec ? "7 4" : "none"}
opacity={ts.opacity}
/>
<line
x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
stroke="transparent"
strokeWidth={14}
strokeLinecap="round"
style={{ cursor: "pointer" }}
onPointerDown={(ev) => { ev.stopPropagation() }}
onClick={openGreDetail}
>
<title>GRE: подробнее</title>
</line>
{showAnimDots && !reducedMotion && ts.dotOpacity > 0 && (
<circle r="3" fill={ts.stroke} opacity={ts.dotOpacity} pointerEvents="none">
<animateMotion dur={`${2.4 + (i % 5) * 0.35}s`} repeatCount="indefinite"
path={`M ${e.from.x} ${e.from.y} L ${e.to.x} ${e.to.y}`} />
</circle>
)}
{showPingBadges && greTunnelIdsWithMapBadge.has(edgeId) && (
<GreEdgeMetricBadge
mx={mx}
my={my}
pingMs={merged.pingMs}
dl={merged.dlMbps}
ul={merged.ulMbps}
onOpen={openGreDetail}
probe={spGre}
rttFromMonitor={rttFromMonitor}
throughputFromMonitor={throughputFromMonitor}
outerSummary={greOuterSummaryLine(e.tunnel, fromN, toN, greResolvedMap)}
/>
)}
{showNetflow && hopHasRate(flowHop) && (
<NetflowRateBadge
mx={flowPos.mx}
my={flowPos.my}
hop={flowHop}
onOpen={openGreDetail}
/>
)}
</g>
)
})}
{/* ── Home Router → WAN satellite connectors ── */}
{homeRouters.flatMap(router => {
const rNode = nodes.find(n => n.id === router.id)
if (!rNode) return []
return (router.wanUplinks ?? []).map((wan, wIdx) => {
const sat = effectiveSatPos[router.id]?.[wIdx]
const color = WAN_COLORS[wIdx] ?? "var(--muted-foreground)"
const vis = filter === "all" || filter === "home-router" || filter === "online"
if (!sat) return null
return (
<g key={`${router.id}-conn-${wIdx}`} opacity={vis ? 1 : 0.06} style={{ transition: "opacity 0.3s" }}>
<line x1={rNode.x} y1={rNode.y} x2={sat.x} y2={sat.y}
stroke={color} strokeWidth="2" opacity="0.55" />
</g>
)
}).filter(Boolean)
})}
{/* ── WAN→JH edges ── */}
{visibleWanJhEdges.map((edge) => {
const jh = nodeById[edge.jhId]
const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx]
if (!jh || !satPos) return null
const edgeKey = `${edge.homeId}-${edge.wanIdx}-${edge.jhId}`
const color = WAN_COLORS[edge.wanIdx] ?? "var(--muted-foreground)"
const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online"
const { mx, my } = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.62, -17)
const flowPos = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.38, 18)
const isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx)
const wanFlow = netflowByWanKey.get(wanJhEdgeMapKey(edge))
return (
<g key={edgeKey} opacity={vis ? (isHL ? 1 : 0.45) : 0.05}
style={{ transition: "opacity 0.3s" }}>
<line
x1={satPos.x} y1={satPos.y} x2={jh.x} y2={jh.y}
stroke={color}
strokeWidth={edge.active ? (hopHasRate(wanFlow) ? 2.8 : 2) : 1.2}
strokeDasharray={edge.active ? "none" : "5 4"}
opacity={edge.active ? 0.7 : 0.4}
filter={isHL ? `url(#glow-wan-${edge.wanIdx})` : undefined}
/>
{showAnimDots && !reducedMotion && edge.active && (
<circle r="3.5" fill={color} opacity="0.85">
<animateMotion dur={`${1.8 + (edge.wanIdx % 5) * 0.3}s`} repeatCount="indefinite"
path={`M ${satPos.x} ${satPos.y} L ${jh.x} ${jh.y}`} />
</circle>
)}
{showPingBadges && (() => {
const kj = wanJhEdgeMapKey(edge)
const sp = speedProbeByWanJhKey.get(kj)
const merged = mergeGreMetricsWithSpeedProbe(sp, {
pingMs: useLiveData?null:edge.pingMs,
dlMbps: null,
ulMbps: null,
})
const hasMonitor = merged.hasSpeedMonitor
if (!hasMonitor && suppressWanJhPingBadge.has(kj)) return null
if (hasMonitor && suppressWanJhSpeedBadgeDuplicate.has(kj)) return null
if (hasMonitor) {
return (
<GreEdgeMetricBadge
mx={mx}
my={my}
pingMs={merged.pingMs}
dl={merged.dlMbps}
ul={merged.ulMbps}
onOpen={(ev) => openWanJhSpeedDetail(ev, edge)}
probe={sp}
rttFromMonitor={sp?.lastPingRttMs != null}
throughputFromMonitor={
sp?.lastTxAvgMbps != null || sp?.lastRxAvgMbps != null
}
/>
)
}
return <PingBadge mx={mx} my={my} ping={useLiveData?null:edge.pingMs} color={pingColor(useLiveData?null:edge.pingMs)} />
})()}
{showNetflow && hopHasRate(wanFlow) && (
<NetflowRateBadge
mx={flowPos.mx}
my={flowPos.my}
hop={wanFlow}
onOpen={(ev) => openWanJhSpeedDetail(ev, edge)}
/>
)}
</g>
)
})}
{/* ── Server nodes ── */}
{nodes.map(n => (
<ServerNode
key={n.id} n={n}
isSel={selected?.id === n.id}
isVis={isVisible(n)}
isDragged={draggedNodeId === n.id}
hideCatalogLatency={hideCatalogLatencyByNodeId.has(n.id)}
onClick={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return }
selectServer(n)
}}
onDblClick={() => { selectServer(n); focusNode(n.x, n.y) }}
onHoverChange={h => setHoveredId(h ? n.id : null)}
onContextMenu={e => setCtxMenu({ x: e.clientX, y: e.clientY, server: n })}
onMouseDown={e => onNodeMouseDown(e, n)}
/>
))}
{/* ── WAN satellite nodes ── */}
{homeRouters.flatMap(router => {
const vis = filter === "all" || filter === "home-router" || filter === "online"
return (router.wanUplinks ?? []).map((wan, wIdx) => {
const sat = effectiveSatPos[router.id]?.[wIdx]
if (!sat) return null
const color = WAN_COLORS[wIdx] ?? "var(--muted-foreground)"
const isActive = wanJhEdges.filter(e => e.homeId === router.id && e.wanIdx === wIdx).some(e => e.active)
const isSatSel = selected?.id === router.id && selWanIdx === wIdx
const satKey = `${router.id}-${wIdx}`
return (
<g key={`${router.id}-wan-${wIdx}`} opacity={vis ? 1 : 0.06} style={{ transition: "opacity 0.3s" }}>
<WanSatNode
x={sat.x} y={sat.y} wan={wan} color={color}
active={isActive} isSel={isSatSel}
isDragged={draggedSatKey === satKey}
onSelect={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return }
selectWan(router, wIdx)
}}
onMouseDown={e => onSatMouseDown(e, router.id, wIdx, sat.x, sat.y)}
/>
</g>
)
}).filter(Boolean)
})}
{visibleMapServices.map((svc) => {
const pos = servicePosById[svc.id]
if (!pos) return null
return (
<ServiceNode
key={svc.id}
label={destDisplayLabel(svc, destMode)}
share={svc.share}
x={pos.x}
y={pos.y}
isSel={selectedService?.id === svc.id}
isVis
dim={Boolean(expandedCountryGroup) && svc.id !== expandedCountryId}
isDragged={draggedSvcId === svc.id}
destMode={destMode}
iso={svc.label}
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
onClick={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return }
selectService(svc)
}}
/>
)
})}
{/* ── Сервисы раскрытой страны (второй столбец справа) ── */}
{expandedCountryGroup && nestedServices.map((svc) => {
const pos = nestedPosById[svc.id]
if (!pos) return null
return (
<ServiceNode
key={svc.id}
label={svc.label}
share={svc.share}
x={pos.x}
y={pos.y}
isSel={selectedService?.id === svc.id}
isVis
isDragged={draggedSvcId === svc.id}
destMode="services"
shareLabel="трафика страны"
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
onClick={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return }
selectService(svc)
}}
/>
)
})}
{/* ── EN → destination services (поверх узлов, чтобы пунктир не прятался) ── */}
{visibleServiceEdges.map((edge) => {
const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
const to = servicePosById[edge.toId]
if (!from || !to) return null
const hop: MatchedNetflowHop = {
bytes: edge.bytes,
bps: edge.bps,
bpsFwd: edge.bpsFwd,
bpsRev: edge.bpsRev,
}
const fromR = "type" in from && from.type
? TYPE_STYLE[from.type].r
: TYPE_STYLE["exit-node"].r
const clipped = clipSegmentCircleToRect(
from.x,
from.y,
fromR,
to.x,
to.y,
MAP_SERVICE_NODE_W / 2,
MAP_SERVICE_NODE_H / 2,
)
const { mx, my } = edgeBadgePosition(clipped.x1, clipped.y1, clipped.x2, clipped.y2, 0.55, 16)
const pathHit = Boolean(
highlightedPath
&& highlightedPath.enId === edge.fromId
&& highlightedPath.serviceId === edge.toId,
)
const pathDim = Boolean(highlightedPath) && !pathHit
const hl = pathHit || (!highlightedPath && (selectedService?.id === edge.toId || selected?.id === edge.fromId || expandedCountryId === edge.toId))
const countryDim = !hl && Boolean(expandedCountryGroup) && edge.toId !== expandedCountryId
const svc = visibleMapServices.find((s) => s.id === edge.toId)
const enName = mapServers.find((s) => s.id === edge.fromId)?.name ?? edge.fromId
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
const pathTitle = `${clientLabel} → ${enName} → ${destDisplayLabel(svc, destMode, edge.toId)}`
return (
<g
key={`${edge.fromId}|${edge.toId}`}
opacity={pathDim ? 0.12 : hl ? 1 : countryDim ? 0.35 : 0.72}
style={{ transition: "opacity 0.3s" }}
>
<title>{pathTitle}</title>
<line
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
stroke="var(--chart-4)"
strokeWidth={pathHit ? 3.2 : hopHasRate(hop) ? 2.4 : 1.4}
strokeDasharray="6 5"
opacity="0.85"
pointerEvents="none"
/>
<line
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
stroke="transparent"
strokeWidth={14}
strokeLinecap="round"
style={{ cursor: "pointer" }}
onPointerDown={(ev) => { ev.stopPropagation() }}
onClick={(ev) => {
ev.stopPropagation()
if (svc) selectService(svc)
}}
/>
{showAnimDots && !reducedMotion && hopHasRate(hop) && (
<circle r="3" fill="var(--chart-4)" opacity="0.85" pointerEvents="none">
<animateMotion dur="2.6s" repeatCount="indefinite"
path={`M ${clipped.x1} ${clipped.y1} L ${clipped.x2} ${clipped.y2}`} />
</circle>
)}
{hopHasRate(hop) && (
<NetflowRateBadge
mx={mx}
my={my}
hop={hop}
onOpen={(ev) => {
ev.stopPropagation()
const hit = visibleMapServices.find((s) => s.id === edge.toId)
if (hit) selectService(hit)
}}
/>
)}
</g>
)
})}
{/* ── Раскрытая страна → её сервисы ── */}
{expandedCountryGroup?.edges.map((edge) => {
const from = servicePosById[edge.fromId]
const to = nestedPosById[edge.toId]
if (!from || !to) return null
const hop: MatchedNetflowHop = {
bytes: edge.bytes,
bps: edge.bps,
bpsFwd: edge.bpsFwd,
bpsRev: edge.bpsRev,
}
const clipped = clipSegmentCircleToRect(
from.x,
from.y,
MAP_SERVICE_NODE_W / 2,
to.x,
to.y,
MAP_SERVICE_NODE_W / 2,
MAP_SERVICE_NODE_H / 2,
)
const { mx, my } = edgeBadgePosition(clipped.x1, clipped.y1, clipped.x2, clipped.y2, 0.55, 16)
const hl = selectedService?.id === edge.toId
const svc = nestedServices.find((s) => s.id === edge.toId)
const country = mapCountries.find((s) => s.id === edge.fromId)
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
const pathTitle = `${clientLabel} → ${destDisplayLabel(country, "countries", edge.fromId)} → ${edge.toId.split("|")[1] ?? edge.toId}`
return (
<g
key={`${edge.fromId}|${edge.toId}`}
opacity={hl ? 1 : 0.72}
style={{ transition: "opacity 0.3s" }}
>
<title>{pathTitle}</title>
<line
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
stroke="var(--chart-4)"
strokeWidth={hopHasRate(hop) ? 2 : 1.3}
strokeDasharray="4 4"
opacity="0.9"
pointerEvents="none"
/>
<line
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
stroke="transparent"
strokeWidth={14}
strokeLinecap="round"
style={{ cursor: "pointer" }}
onPointerDown={(ev) => { ev.stopPropagation() }}
onClick={(ev) => {
ev.stopPropagation()
if (svc) selectService(svc)
}}
/>
{showAnimDots && !reducedMotion && hopHasRate(hop) && (
<circle r="3" fill="var(--chart-4)" opacity="0.85" pointerEvents="none">
<animateMotion dur="2.6s" repeatCount="indefinite"
path={`M ${clipped.x1} ${clipped.y1} L ${clipped.x2} ${clipped.y2}`} />
</circle>
)}
{hopHasRate(hop) && (
<NetflowRateBadge
mx={mx}
my={my}
hop={hop}
onOpen={(ev) => {
ev.stopPropagation()
const hit = nestedServices.find((s) => s.id === edge.toId)
if (hit) selectService(hit)
}}
/>
)}
</g>
)
})}
{/* ── Hover tooltip ── */}
{hoveredNode && !isDragging && (
<SvgTooltip n={hoveredNode} />
)}
{/* ── Legend (viewport-fixed) ── */}
<g className="hidden lg:block" aria-hidden="true" transform={`translate(${pan.x + 14}, ${pan.y + 14})`}>
<rect width="140" height="250" rx="8"
fill="var(--popover)" stroke="var(--border)" strokeWidth="1" />
<text x="10" y="22" fontSize="8" fontWeight="700" fill="var(--muted-foreground)"
fontFamily="system-ui" letterSpacing="0.08em">ЛЕГЕНДА</text>
{(["online","degraded","offline","unknown"] as const).map((s, i) => (
<g key={s} transform={`translate(10, ${30 + i * 16})`}>
<circle cx="5" cy="0" r="4.5" fill={STATUS_STYLE[s].stroke} opacity="0.85" />
<text x="16" y="4" fontSize="8.5" fill="var(--foreground)" fontFamily="system-ui">
{s === "online" ? "Онлайн" : s === "degraded" ? "Внимание" : s === "offline" ? "Оффлайн" : "Неизвестно"}
</text>
</g>
))}
<line x1="10" y1="100" x2="130" y2="100" stroke="var(--border)" strokeWidth="1" />
{[
{ fill: "var(--chart-1)", label: "JH", text: "JumpHost" },
{ fill: "var(--chart-3)", label: "EN", text: "Exit Node" },
{ fill: "var(--chart-2)", label: "HR", text: "Home Router" },
].map((t, i) => (
<g key={t.label} transform={`translate(10, ${110 + i * 18})`}>
<rect width="14" height="14" rx="3" fill={t.fill} />
<text x="5.5" y="11" fontSize="6" fontWeight="bold" fill="var(--primary-foreground)"
textAnchor="middle" fontFamily="system-ui">{t.label}</text>
<text x="22" y="11" fontSize="8.5" fill="var(--foreground)" fontFamily="system-ui">{t.text}</text>
</g>
))}
<g transform="translate(10, 164)">
<rect width="14" height="14" rx="4" fill="var(--card)" stroke="var(--chart-4)" strokeWidth="1.2" />
<text x="22" y="11" fontSize="8.5" fill="var(--foreground)" fontFamily="system-ui">
{destMode === "countries" ? "Страна" : "Сервис"}
</text>
</g>
<line x1="10" y1="186" x2="130" y2="186" stroke="var(--border)" strokeWidth="1" />
<text x="10" y="200" fontSize="7.5" fontWeight="700" fill="var(--muted-foreground)"
fontFamily="system-ui" letterSpacing="0.05em">WAN АПЛИНКИ</text>
{WAN_COLORS.slice(0, 2).map((c, i) => (
<g key={i} transform={`translate(10, ${210 + i * 14})`}>
<circle cx="5" cy="4" r="4" fill={c} opacity="0.9" />
<text x="16" y="8" fontSize="8" fill="var(--muted-foreground)" fontFamily="ui-monospace,monospace">
WAN{i + 1}
</text>
<line x1="38" y1="4" x2="60" y2="4" stroke={c}
strokeWidth={i === 0 ? 2 : 1.2} strokeDasharray={i === 0 ? "none" : "4 3"} opacity="0.8" />
<text x="66" y="8" fontSize="7.5" fill="var(--muted-foreground)" fontFamily="system-ui">
{useLiveData?"модель":i === 0 ? "primary (демо)" : "backup (демо)"}
</text>
</g>
))}
</g>
{/* ── Keyboard hints (viewport-fixed) ── */}
{showHints && (
<g transform={`translate(${pan.x + W / zoom - 14}, ${pan.y + H / zoom - 14})`}>
{[
["Scroll", "zoom"],
["+/-", "zoom in/out"],
["F / 0", "вписать"],
["M", "minimap"],
["P", "ping"],
["Esc", "снять выбор"],
["2× клик", "навигация"],
].reverse().map(([k, l], i) => (
<g key={k} transform={`translate(0, ${-(i * 16)})`}>
<rect x="-120" y="-12" width="120" height="14" rx="3"
fill="var(--popover)" />
<text x="-60" y="-1" textAnchor="middle" fontSize="7.5"
fill="var(--muted-foreground)" fontFamily="system-ui">
<tspan fontWeight="600" fill="var(--muted-foreground)">{k}</tspan> {l}
</text>
</g>
))}
</g>
)}
</svg>
{/* ── Zoom controls ── */}
<ZoomControls
zoom={zoom}
onZoomIn={() => applyZoomCenter(1.3)}
onZoomOut={() => applyZoomCenter(1 / 1.3)}
onFit={fitView}
/>
{/* ── Minimap ── */}
{showMinimap && (
<Minimap
pan={pan} zoom={zoom}
nodes={nodes}
greEdges={greEdges}
satPos={effectiveSatPos}
wanJhEdges={visibleWanJhEdges}
homeRouters={homeRouters}
servicePos={{ ...servicePosById, ...nestedPosById }}
onClose={() => setShowMinimap(false)}
onPan={(x, y) => setPan({ x, y })}
/>
)}
{/* ── "Show minimap" button when hidden ── */}
{!showMinimap && (
<button type="button"
onClick={() => setShowMinimap(true)}
className="absolute bottom-16 right-4 z-20 p-2 rounded-lg border border-border
bg-popover/95 backdrop-blur-sm text-muted-foreground hover:text-foreground transition-colors"
aria-label="Показать миникарту" title="Показать миникарту (M)">
<MapIcon aria-hidden="true" className="size-3.5" />
</button>
)}
{/* ── Search result count ── */}
{sq && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-20 px-3 py-1.5 rounded-full
border border-border bg-popover/95 backdrop-blur-sm text-xs font-mono text-muted-foreground">
{nodes.filter(isVisible).length === 0
? "Ничего не найдено"
: `Найдено: ${nodes.filter(isVisible).length} узл${nodes.filter(isVisible).length === 1 ? "" : nodes.filter(isVisible).length < 5 ? "а" : "ов"}`}
</div>
)}
</div>
{/* ── Side panel (узел или выбранное GRE-ребро) ── */}
{(selectedGreEdge || selected || selectedService) && (
<div role="complementary" aria-label="Детали объекта карты" className="border-t lg:border-t-0 lg:border-l flex flex-col overflow-hidden shrink-0 bg-background w-full lg:w-80 h-full min-h-0 min-w-0">
{selectedGreEdge ? (
<>
<div className="flex items-start gap-2 px-4 py-3 border-b">
<div className="flex-1 min-w-0">
<p className="font-mono font-semibold text-sm [overflow-wrap:anywhere]">{selectedGreEdge.tunnel.name}</p>
<p className="text-xs text-muted-foreground font-mono mt-0.5 leading-snug [overflow-wrap:anywhere]">
GRE · {selectedGreEdge.fromServer.name} ({TYPE_LABELS[selectedGreEdge.fromServer.type]}) ↔{" "}
{selectedGreEdge.toServer.name} ({TYPE_LABELS[selectedGreEdge.toServer.type]})
</p>
</div>
<div className="flex items-center gap-1 mt-0.5">
<button type="button"
onClick={() => {
const mx = (selectedGreEdge.from.x + selectedGreEdge.to.x) / 2
const my = (selectedGreEdge.from.y + selectedGreEdge.to.y) / 2
focusNode(mx, my)
}}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Навести на карте" title="Навести на карте">
<HomeIcon aria-hidden="true" className="size-3.5" />
</button>
<button type="button"
aria-label="Закрыть детали GRE" onClick={() => setSelectedGreEdge(null)}
className="text-muted-foreground hover:text-foreground transition-colors">
<XIcon aria-hidden="true" className="size-4" />
</button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 flex flex-col gap-5">
{(() => {
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 (
<>
<div className="flex flex-wrap gap-2">
<span
className={cn(
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium",
tunnelStatusBadge,
)}
>
<StatusDot status={greStatusToServerStatus(t.status)} />
{tunnelStatusLabel}
</span>
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium
bg-amber-950/40 text-amber-400 border border-amber-500/20"
>
GRE-туннель
</span>
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Метрики на карте
</p>
<div className="flex flex-col gap-0">
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">
{rttMon ? "Мониторинг (ping/BT)" : useLiveData?"Ping неизвестен":"Модель RTT (демо)"}
</span>
<span
className="text-xs font-mono font-semibold"
style={{ color: pingColor(merged.pingMs) }}
>
{merged.pingMs == null ? "—" : `${merged.pingMs} мс`}
</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">
{bwMon ? "TX / RX (BT)" : useLiveData?"TX / RX неизвестны":"Скорость канала (демо)"}
</span>
<span className="text-xs font-mono font-semibold text-sky-400">
{(merged.dlMbps != null || merged.ulMbps != null)
? `↓${merged.dlMbps??"—"} ↑${merged.ulMbps??"—"}`
: "—"}
</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Поток (NetFlow)</span>
<span className="text-xs font-mono font-semibold text-emerald-400">
{(() => {
const hop = netflowByGreKey.get(selectedEdgeId)
if (!hopHasRate(hop)) return "—"
return hop.bpsFwd > 0 && hop.bpsRev > 0
? formatNetflowDir(hop)
: formatNetflowRate(hop)
})()}
</span>
</div>
<p className="text-[10px] text-muted-foreground pt-2 leading-snug">
{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), не ёмкость канала.
</p>
</div>
</div>
<div className="flex flex-col gap-0">
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Статус туннеля</span>
<span className="text-xs font-mono font-medium" style={{ color: ts.stroke }}>{t.status}</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">На узле</span>
<span className="text-xs font-mono font-medium [overflow-wrap:anywhere] text-right">{edge.fromServer.name}</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Пир (каталог)</span>
<span className="text-xs font-mono font-medium [overflow-wrap:anywhere] text-right">{edge.toServer.name}</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Включён</span>
<span className="text-xs font-mono font-medium [overflow-wrap:anywhere] text-right">{t.enabled ? "да" : "нет"}</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">IPsec</span>
<span className="text-xs font-mono font-medium flex items-center gap-1 justify-end">
{t.ipsec ? (
<><LockIcon aria-hidden="true" className="size-3 text-emerald-400 shrink-0" /> да</>
) : (
<><LockOpenIcon aria-hidden="true" className="size-3 shrink-0" /> {useLiveData?"неизвестно":"нет (демо)"}</>
)}
</span>
</div>
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Адреса и параметры
</p>
<div className="flex flex-col gap-0">
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Local outer</span>
<span className="text-xs font-mono font-medium text-right [overflow-wrap:anywhere] max-w-[62%]">
{t.localAddress || "—"}
</span>
</div>
{localWanName && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-[10px] text-muted-foreground">WAN на узле</span>
<span className="text-[10px] font-mono font-medium">{localWanName}</span>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Remote outer</span>
<span className="text-xs font-mono font-medium text-right [overflow-wrap:anywhere] max-w-[62%]">
{t.remoteAddress || "—"}
</span>
</div>
{remoteWanName && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-[10px] text-muted-foreground">WAN у пира</span>
<span className="text-[10px] font-mono font-medium">{remoteWanName}</span>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Inner</span>
<span className="text-xs font-mono font-medium text-right">
{t.localInnerIp} ↔ {t.remoteInnerIp}
</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">MTU / Keepalive</span>
<span className="text-xs font-mono font-medium [overflow-wrap:anywhere] text-right">
{t.mtu} /{" "}
{t.keepaliveInterval === 0 ? "выкл." : `${t.keepaliveInterval}s×${t.keepaliveRetries}`}
</span>
</div>
</div>
</div>
<Link
href="/gre"
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-dashed border-border/60
text-xs text-muted-foreground hover:text-foreground hover:border-border transition-colors"
>
Открыть раздел GRE-туннелей
</Link>
</>
)
})()}
</div>
</>
) : selectedNestedService ? (
<>
<div className="flex items-start gap-2 px-4 py-3 border-b">
<div className="mt-0.5">
<ServiceBrandIcon aria-hidden="true" label={selectedNestedService.label} size={22} />
</div>
<div className="flex-1 min-w-0">
<p className="font-mono font-semibold text-sm [overflow-wrap:anywhere]">
{selectedNestedService.label}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{`Сервис в ${destDisplayLabel(expandedCountry ?? undefined, "countries")} · ${selectedNestedService.category}`}
</p>
</div>
<button type="button"
aria-label="Закрыть детали назначения" onClick={() => setSelectedService(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<XIcon aria-hidden="true" className="size-4" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 flex flex-col gap-4">
<div className="flex flex-col gap-0">
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Доля страны</span>
<span className="text-xs font-mono font-medium text-cyan-400">{serviceSharePct(selectedNestedService.share)}</span>
</div>
{mapTotalBytes > 0 && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Доля окна</span>
<span className="text-xs font-mono font-medium text-cyan-400">
{serviceSharePct(selectedNestedService.bytes / mapTotalBytes)}
</span>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Скорость</span>
<span className="text-xs font-mono font-medium [overflow-wrap:anywhere] text-right">
{formatNetflowRate({
bytes: selectedNestedService.bytes,
bps: selectedNestedService.bps,
bpsFwd: selectedNestedService.bps,
bpsRev: 0,
})}
</span>
</div>
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Выход</p>
<ServicePathList
paths={(expandedCountryGroup?.paths ?? [])
.filter((p) => p.serviceId === selectedNestedService.id)
.slice()
.sort((a, b) => b.bps - a.bps)}
servers={mapServers}
services={nestedServices}
highlight={highlightedPath}
viaMode="via"
destMode="services"
onToggle={togglePathHighlight}
/>
</div>
</div>
</>
) : liveSelectedService ? (
<>
<div className="flex items-start gap-2 px-4 py-3 border-b">
<div className="mt-0.5">
{destMode === "countries" && liveSelectedService.label !== "Прочее" && liveSelectedService.id !== "cc:other"
? <Flag code={liveSelectedService.label} size={22} />
: <ServiceBrandIcon aria-hidden="true" label={destMode === "countries" ? "Прочее" : liveSelectedService.label} size={22} />}
</div>
<div className="flex-1 min-w-0">
<p className="font-mono font-semibold text-sm [overflow-wrap:anywhere]">
{destDisplayLabel(liveSelectedService, destMode)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{destMode === "countries"
? `Конечная страна${liveSelectedService.label !== "Прочее" ? ` · ${liveSelectedService.label}` : ""}`
: `Конечный сервис · ${liveSelectedService.category}`}
</p>
</div>
<button type="button"
aria-label="Закрыть детали назначения" onClick={() => setSelectedService(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<XIcon aria-hidden="true" className="size-4" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 flex flex-col gap-4">
<div className="flex flex-col gap-0">
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Доля окна</span>
<span className="text-xs font-mono font-medium text-cyan-400">{serviceSharePct(liveSelectedService.share)}</span>
</div>
{mapTotalBytes > 0 && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Классифицировано</span>
<span className="text-xs font-mono font-medium text-cyan-400">
{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,
})}
</span>
</div>
)}
{!mapAsnLoaded && (!useLiveData||flowReadAt) && destMode === "services" && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">GeoLite2 ASN</span>
<span className="text-xs font-mono font-medium text-amber-500">не загружена</span>
</div>
)}
{destMode === "countries" && !mapCountryLoaded && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">GeoIP Country</span>
<span className="text-xs font-mono font-medium text-amber-500">RIPE-кэш</span>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Скорость</span>
<span className="text-xs font-mono font-medium [overflow-wrap:anywhere] text-right">
{formatNetflowRate({
bytes: liveSelectedService.bytes,
bps: liveSelectedService.bps,
bpsFwd: liveSelectedService.bps,
bpsRev: 0,
})}
</span>
</div>
</div>
{expandedCountryGroup && liveSelectedService.id === expandedCountryGroup.countryId && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Сервисы в стране</p>
<div className="flex flex-col gap-1">
{expandedCountryGroup.services.map((svc) => (
<button type="button"
key={svc.id}
onClick={() => selectService(svc)}
className={cn(
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
selectedService?.id === svc.id ? "bg-cyan-500/15 ring-1 ring-cyan-500/40" : "hover:bg-muted/50",
)}
>
<span className="flex items-center gap-1.5 min-w-0">
<ServiceBrandIcon aria-hidden="true" label={svc.label} size={14} />
<span className="font-mono truncate">{svc.label}</span>
</span>
<span className="font-mono text-cyan-400 tabular-nums shrink-0">{serviceSharePct(svc.share)}</span>
</button>
))}
</div>
</div>
)}
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Выход</p>
<div className="flex flex-col gap-3">
{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 (
<div key={`${e.fromId}|${e.toId}`} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-xs">
<span className="font-mono truncate">{src?.name ?? e.fromId}</span>
<span className="font-mono text-emerald-400 tabular-nums">
{formatNetflowRate({ bytes: e.bytes, bps: e.bps, bpsFwd: e.bpsFwd, bpsRev: e.bpsRev })}
</span>
</div>
<ServicePathList
paths={enPaths}
servers={mapServers}
services={destNodes}
highlight={highlightedPath}
viaMode="via"
destMode={destMode}
onToggle={togglePathHighlight}
/>
</div>
)
})}
</div>
</div>
</div>
</>
) : selected ? (
<>
<div className="flex items-start gap-2 px-4 py-3 border-b">
<div className="flex-1 min-w-0">
<p className="font-mono font-semibold text-sm [overflow-wrap:anywhere]">{selected.name}</p>
<Button type="button" variant="outline" size="sm" className="mt-2" data-map-device-actions onClick={()=>setCtxMenu({x:0,y:0,server:selected})}>Действия устройства</Button>
<p className="text-xs text-muted-foreground font-mono mt-0.5">{selected.host}</p>
</div>
<div className="flex items-center gap-1 mt-0.5">
<button type="button"
onClick={() => { const n = nodes.find(x => x.id === selected.id); if (n) focusNode(n.x, n.y) }}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Навести на карте" title="Навести на карте">
<HomeIcon aria-hidden="true" className="size-3.5" />
</button>
<button type="button" aria-label="Закрыть детали устройства" onClick={() => { setSelected(null); setSelWanIdx(null) }}
className="text-muted-foreground hover:text-foreground transition-colors">
<XIcon aria-hidden="true" className="size-4" />
</button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 flex flex-col gap-5">
{/* Badges */}
<div className="flex flex-wrap gap-2">
<StatusBadge status={selected.status} />
<span className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
selected.type === "jump-host" ? "bg-violet-950/40 text-violet-400" :
selected.type === "home-router" ? "bg-emerald-950/40 text-emerald-400" :
"bg-sky-950/40 text-sky-400",
)}>
{TYPE_STYLE[selected.type].label} · {TYPE_LABELS[selected.type]}
</span>
</div>
{/* Info rows */}
<div className="flex flex-col gap-0">
{[
["Площадка", 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]) => (
<div key={k} className="flex items-center justify-between py-2 border-b border-border/50 last:border-0">
<span className="text-xs text-muted-foreground">{k}</span>
<span className="text-xs font-mono font-medium [overflow-wrap:anywhere] text-right">{v}</span>
</div>
))}
<div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Задержка</span>
<span className="text-xs font-mono font-semibold" style={{ color: latencyColor(selected.latency) }}>
{selected.latency == null ? "—" : `${selected.latency} мс`}
</span>
</div>
</div>
{/* WAN uplinks */}
{selected.type === "home-router" && selected.wanUplinks && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
WAN-аплинки ({selected.wanUplinks.length})
</p>
<div className="flex flex-col gap-2">
{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 (
<div key={wan.id}
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 }}>
<div className="flex items-center gap-2 mb-2">
<WifiIcon aria-hidden="true" className="size-3.5 shrink-0" style={{ color }} />
<span className="font-mono text-xs font-bold" style={{ color }}>{wan.name}</span>
<span className={cn(
"ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full",
isActive ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground",
)}>
{useLiveData?"Аплинк каталога":isActive ? "primary (демо)" : "backup (демо)"}
</span>
</div>
<div className="flex flex-col gap-1">
{[["ISP", wan.isp], ["Iface", wan.iface], ["IP", wan.ip],
["Канал", `↓${wan.maxDl} ↑${wan.maxUl} Мбит`]].map(([k, v]) => (
<div key={k} className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{k}</span>
<span className="text-[10px] font-mono">{v}</span>
</div>
))}
{(() => {
const hop = netflowByWanKey.get(`${selected.id}\t${wIdx}`)
if (!hopHasRate(hop)) return null
return (
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Поток</span>
<span className="text-[10px] font-mono text-emerald-400">
{hop.bpsFwd > 0 && hop.bpsRev > 0
? formatNetflowDir(hop)
: formatNetflowRate(hop)}
</span>
</div>
)
})()}
</div>
{myEdges.length > 0 && (
<div className="mt-2 pt-2 border-t border-border/40">
<p className="text-[9px] uppercase text-muted-foreground tracking-wider mb-1.5">
Модель связей с JH · Ping модели
</p>
{myEdges.map((e) => {
const jh = mapServers.find(s => s.id === e.jhId)
const edgeKey = `${e.homeId}-${e.wanIdx}-${e.jhId}`
return (
<div key={edgeKey} className="flex items-center justify-between py-0.5">
<span className="text-[10px] font-mono text-muted-foreground truncate">
→ {jh?.name ?? e.jhId}
</span>
<span className="text-[10px] font-mono font-semibold shrink-0 ml-2"
style={{ color: pingColor(e.pingMs) }}>
{e.pingMs == null ? "—" : `${e.pingMs} мс`}
</span>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* GRE tunnels */}
{connectedTunnels.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
GRE-туннели ({connectedTunnels.length})
</p>
<div className="flex flex-col gap-2">
{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 (
<div key={tunnelPanelKey} className="rounded-md border border-border/60 px-3 py-2 bg-muted/20">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-mono font-medium [overflow-wrap:anywhere] text-right">{t.name}</span>
<div className="flex items-center gap-2">
<span className={cn(
"text-[10px] font-medium px-1.5 py-0.5 rounded-full",
isPrimary ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground",
)}>
{isPrimary ? "Основная на схеме" : "Дополнительная на схеме"}
</span>
<span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
{t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
</span>
</div>
</div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[200px]">
→ {peer ? `${peer.name} · ${peer.site}` : t.remoteAddress}
</span>
{t.ipsec
? <span className="text-[10px] text-emerald-400 flex items-center gap-0.5"><LockIcon aria-hidden="true" className="size-2.5" />IPsec</span>
: <span className="text-[10px] text-muted-foreground flex items-center gap-0.5"><LockOpenIcon aria-hidden="true" className="size-2.5" />{useLiveData?"IPsec неизвестен":"Plain (демо)"}</span>}
</div>
{showMetrics && (
<div className="flex items-center justify-between pt-1 border-t border-border/30">
<span className="text-[9px] text-muted-foreground">
{merged.hasSpeedMonitor ? "Мониторинг (ping / TX·RX)" : "Модель ping / speed"}
</span>
<span className="text-[10px] font-mono" style={{ color: pc }}>
{merged.pingMs != null ? `${merged.pingMs} мс` : "—"}
{merged.dlMbps != null && merged.ulMbps != null && (
<span className={cn(
"text-[9px] ml-1.5",
merged.hasSpeedMonitor ? "text-sky-400/90" : "text-muted-foreground",
)}>
↓{merged.dlMbps} ↑{merged.ulMbps}
</span>
)}
{hopHasRate(greFlow) && (
<span className="text-[9px] ml-1.5 text-emerald-400">
{formatNetflowRate(greFlow)}
</span>
)}
</span>
</div>
)}
</div>
)
})}
</div>
</div>
)}
{(selected.type === "jump-host" || selected.type === "exit-node") && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Пути</p>
<ServicePathList
paths={destPaths
.filter((p) => (
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}
/>
</div>
)}
{/* 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 (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">
Ресурсы
</p>
{!res.fromPoll && (
<p className="text-[9px] text-muted-foreground mb-3 leading-snug">
Нет снапшота опроса — показаны демо-значения. Включите опрос серверов или откройте «Серверы».
</p>
)}
{res.fromPoll && (
<p className="text-[9px] text-muted-foreground mb-3 leading-snug">
Последний poll: {res.measuredAt ? new Date(res.measuredAt).toLocaleString("ru-RU") : "время неизвестно"}. Неизвестные ресурсы — «—». История CPU и диск API не передаются.
</p>
)}
{/* CPU with sparkline */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground">CPU</span>
<span className={cn("text-[10px] font-mono font-semibold", resPctColor(cpuPct))}>
{cpuPct==null?"—":cpuPct+"%"}
</span>
</div>
<div className="flex items-center gap-2">
{cpuPct!=null&&<MiniBar pct={cpuPct} />}
{res.cpuHistory.length>1&&<Sparkline history={res.cpuHistory} />}
</div>
</div>
{/* RAM */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground">RAM</span>
<span className={cn("text-[10px] font-mono font-semibold", resPctColor(ramPct))}>
{fmtMB(res.ramUsed)} / {fmtMB(res.ramTotal)}
</span>
</div>
{ramPct!=null&&<MiniBar pct={ramPct} />}
</div>
{/* HDD (нет в GET /api/servers — всегда условная шкала) */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground">
HDD
{!useLiveData && (
<span className="text-[8px] font-normal text-muted-foreground/70 ml-1">(оценка)</span>
)}
</span>
<span className="text-[10px] font-mono text-muted-foreground">
{fmtMB(res.hddUsed)} / {fmtMB(res.hddTotal)}
</span>
</div>
{hddPct!=null&&<MiniBar pct={hddPct} />}
</div>
{/* Misc metrics */}
<div className="flex flex-col gap-0">
<div className="flex items-center justify-between py-1.5 border-b border-border/40">
<span className="text-[10px] text-muted-foreground">Uptime</span>
<span className="text-[10px] font-mono">{fmtUptime(res.uptimeSeconds)}</span>
</div>
{res.temp != null && (
<div className="flex items-center justify-between py-1.5 border-b border-border/40">
<span className="text-[10px] text-muted-foreground">Температура</span>
<span className={cn(
"text-[10px] font-mono font-semibold",
res.temp >= 70 ? "text-red-400" : res.temp >= 55 ? "text-amber-400" : "text-emerald-400",
)}>
{res.temp}°C
</span>
</div>
)}
<div className="flex items-center justify-between py-1.5">
<span className="text-[10px] text-muted-foreground">Плата</span>
<span className="text-[10px] font-mono">{selected.model || "—"}</span>
</div>
</div>
</div>
)
})()}
{/* Navigate hint */}
<button type="button"
onClick={() => { const n = nodes.find(x => x.id === selected.id); if (n) focusNode(n.x, n.y) }}
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-dashed border-border/60
text-xs text-muted-foreground hover:text-foreground hover:border-border transition-colors">
<HomeIcon aria-hidden="true" className="size-3" />
Навести карту на узел
</button>
</div>
</>
) : null}
</div>
)}
</FramePanel>
</Frame>
{/* ── Context menu ── */}
{ctxMenu&&currentCtxServer&&<ContextMenu menu={{...ctxMenu,server:currentCtxServer}} onClose={()=>setCtxMenu(null)} />}
</div>
)
}