3583 lines
179 KiB
TypeScript
3583 lines
179 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useRef, useEffect, useMemo, useCallback, Suspense } from "react"
|
||
import {fitNetworkMapCamera} from "@/lib/network-map-camera"
|
||
import { useSearchParams } from "next/navigation"
|
||
import {readNetworkMapUrl,patchNetworkMapUrl,type NetworkMapFilter} from "@/lib/network-map-url-state"
|
||
import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert"
|
||
import {Frame,FramePanel} from "@/components/reui/frame"
|
||
import {Popover,PopoverTrigger,PopoverContent} from "@/components/ui/popover"
|
||
import {Dialog,DialogContent,DialogHeader,DialogTitle,DialogDescription} from "@/components/ui/dialog"
|
||
import {usePrefersReducedMotion} from "@/hooks/use-prefers-reduced-motion"
|
||
import {toast} from "sonner"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { NetworkMapLegend } from "@/components/network-map-legend"
|
||
import {
|
||
servers as mockServers,
|
||
greTunnels as mockGreTunnels,
|
||
type GreTunnel,
|
||
type Server,
|
||
type ServerStatus,
|
||
type ServerType,
|
||
} from "@/lib/data"
|
||
import {validateNetworkMapSnapshot,networkMapFlowSchema,validateNetworkMapDns,type NetworkMapServerRow} from "@/lib/network-map-response"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
import { requestJson } from "@/shared/api/http-client"
|
||
import {
|
||
buildGreMapEdges,
|
||
buildServerResourceMap,
|
||
buildWanJhEdges,
|
||
clipSegmentCircleToRect,
|
||
computeNetworkMapLayout,
|
||
MAP_SERVICE_NODE_H,
|
||
MAP_SERVICE_NODE_W,
|
||
NETWORK_MAP_H,
|
||
NETWORK_MAP_LAYOUT_REVISION,
|
||
NETWORK_MAP_PIPELINE_Y,
|
||
NETWORK_MAP_W,
|
||
findServerByGreRemote,
|
||
greSourceWanIndexOnMap,
|
||
greTunnelProbe,
|
||
placeCountryServiceNodes,
|
||
placeServiceNodes,
|
||
SERVICE_COL_W,
|
||
type GreMapEdge,
|
||
type WanJhEdge,
|
||
} from "@/lib/network-map-layout"
|
||
import {
|
||
collectGreEndpointHostnames,
|
||
greResolvedMapFromApi,
|
||
isIpv4String,
|
||
normalizeGreEndpointAddr,
|
||
} from "@/lib/gre-endpoint-resolve"
|
||
import {
|
||
assignSpeedProbesToGreTunnels,
|
||
assignSpeedProbesToWanJhEdges,
|
||
canonicalGreServerPairKey,
|
||
greOuterSummaryLine,
|
||
ifaceNameForGreOuterIp,
|
||
mergeGreMetricsWithSpeedProbe,
|
||
wanJhEdgeMapKey,
|
||
type GreSpeedProbeSnapshot,
|
||
} from "@/lib/map-gre-speed-probe"
|
||
import {
|
||
formatNetflowDir,
|
||
formatNetflowRate,
|
||
hopHasRate,
|
||
matchNetflowForGreEdge,
|
||
matchNetflowForWan,
|
||
type MatchedNetflowHop,
|
||
} from "@/lib/map-netflow-hops"
|
||
import type { FlowMapCountryServiceGroup, FlowMapHop, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
|
||
import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon"
|
||
import { CountryFlagSvg } from "@/components/network-map/country-flag-svg"
|
||
import { Button } from "@/components/ui/button"
|
||
import { StatusBadge } from "@/components/status-badge"
|
||
import { StatusDot } from "@/components/status-dot"
|
||
import {
|
||
DownloadIcon, XIcon, LockIcon, LockOpenIcon, WifiIcon, RefreshCwIcon,
|
||
ZoomInIcon, ZoomOutIcon, Maximize2Icon, SearchIcon,
|
||
LayersIcon, MapIcon, HomeIcon, TerminalIcon, ShieldIcon,
|
||
CableIcon, ActivityIcon,
|
||
} from "lucide-react"
|
||
import { cn } from "@/lib/utils"
|
||
import { formatServicePathLabel, formatServicePathTitle } from "@/lib/format-service-path-label"
|
||
import Link from "next/link"
|
||
import { Flag, countryName } from "@/components/flag"
|
||
|
||
// ─── Resource metrics (для мини-блока справа; числа детерминированы по id узла) ─
|
||
|
||
// ─── Backend → frontend (как /servers) ───────────────────────────────────────
|
||
|
||
type BackendServerRow = NetworkMapServerRow
|
||
|
||
function mapBackendRowToServer(s: BackendServerRow): Server {
|
||
return {
|
||
id: String(s.id),
|
||
name: s.name || s.host,
|
||
host: s.host,
|
||
type: s.type,
|
||
site: s.site ?? "—",
|
||
country: s.country,
|
||
asn: s.asn ?? "—",
|
||
model: s.model ?? "—",
|
||
os: s.os ?? "—",
|
||
enabled: s.enabled,
|
||
status: s.status ?? "unknown",
|
||
latency: s.latency ?? null,
|
||
sessions: s.sessions ?? 0,
|
||
comment: s.comment || undefined,
|
||
lanSubnet: s.lanSubnet || undefined,
|
||
wanUplinks: Array.isArray(s.wanUplinks) && s.wanUplinks.length ? s.wanUplinks : undefined,
|
||
uptime: s.uptime ?? undefined,
|
||
cpuLoad: s.cpuLoad ?? undefined,
|
||
freeMemory: s.freeMemory ?? undefined,
|
||
totalMemory: s.totalMemory ?? undefined,
|
||
polledAt: s.polledAt ?? undefined,
|
||
}
|
||
}
|
||
|
||
interface ApiGreTunnelRow {
|
||
id: string
|
||
name: string
|
||
serverId: string
|
||
localAddress: string
|
||
remoteAddress: string
|
||
localInnerIp: string
|
||
remoteInnerIp: string
|
||
poolId: string
|
||
ipsec: { secret: "" } | null
|
||
mtu: number
|
||
keepaliveInterval: number
|
||
keepaliveRetries: number
|
||
dscp: "inherit" | number
|
||
clampTcpMss: boolean
|
||
allowFastPath: boolean
|
||
comment: string
|
||
enabled: boolean
|
||
status: "up" | "down" | "degraded"
|
||
}
|
||
|
||
function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel {
|
||
return {
|
||
id: t.id,
|
||
name: t.name,
|
||
serverId: String(t.serverId),
|
||
localAddress: t.localAddress,
|
||
remoteAddress: t.remoteAddress,
|
||
localInnerIp: t.localInnerIp,
|
||
remoteInnerIp: t.remoteInnerIp,
|
||
poolId: t.poolId || "live",
|
||
ipsec: t.ipsec,
|
||
mtu: t.mtu,
|
||
keepaliveInterval: t.keepaliveInterval,
|
||
keepaliveRetries: t.keepaliveRetries,
|
||
dscp: t.dscp,
|
||
clampTcpMss: t.clampTcpMss,
|
||
allowFastPath: t.allowFastPath,
|
||
comment: t.comment,
|
||
enabled: t.enabled,
|
||
status: t.status,
|
||
}
|
||
}
|
||
|
||
function makeApiFetch(backendUrl: string) {
|
||
return async function apiFetch<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, limit = 24) {
|
||
const label = name.replace(/^(home|mt)-/, "").split("-").slice(0, 3).join("-")
|
||
return label.length > limit ? label.slice(0, limit - 1).trimEnd() + "…" : label
|
||
}
|
||
|
||
function greStatusToServerStatus(s: "up" | "down" | "degraded"): ServerStatus {
|
||
if (s === "up") return "online"
|
||
if (s === "degraded") return "degraded"
|
||
return "offline"
|
||
}
|
||
|
||
/**
|
||
* Позиция бейджа на отрезке: не только середина, а доля t + сдвиг по нормали.
|
||
* GRE и PingBadge (WAN→JH) используют разные t и знак нормали — меньше наложений.
|
||
*/
|
||
function edgeBadgePosition(
|
||
x1: number,
|
||
y1: number,
|
||
x2: number,
|
||
y2: number,
|
||
t: number,
|
||
normalPx: number,
|
||
): { mx: number; my: number } {
|
||
const tc = Math.min(0.82, Math.max(0.18, t))
|
||
const px = x1 + (x2 - x1) * tc
|
||
const py = y1 + (y2 - y1) * tc
|
||
const dx = x2 - x1
|
||
const dy = y2 - y1
|
||
const len = Math.hypot(dx, dy) || 1
|
||
const nx = -dy / len
|
||
const ny = dx / len
|
||
return { mx: px + nx * normalPx, my: py + ny * normalPx }
|
||
}
|
||
|
||
// ─── Filter ───────────────────────────────────────────────────────────────────
|
||
|
||
type FilterKey = NetworkMapFilter
|
||
|
||
function greEdgeKey(e: GreMapEdge): string {
|
||
const la = (e.tunnel.localAddress ?? "").trim()
|
||
const ra = (e.tunnel.remoteAddress ?? "").trim()
|
||
return `${e.fromServer.id}|${e.toServer.id}|${e.tunnel.id}|${la}|${ra}`
|
||
}
|
||
|
||
// ─── SVG sub-components ───────────────────────────────────────────────────────
|
||
|
||
/** Бейдж на ребре WAN → JumpHost (не GRE): сумма latency домашнего узла и JH — те же поля, что в разделе Серверы. */
|
||
function PingBadge({ mx, my, ping, dl, ul, color }: {
|
||
mx: number; my: number
|
||
ping: number | null; dl?: number | null; ul?: number | null
|
||
color: string
|
||
}) {
|
||
const hasSpeed = dl != null && dl > 0
|
||
const h = hasSpeed ? 36 : 20
|
||
return (
|
||
<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="network-map-focusable"
|
||
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 className="network-map-focus-ring" x={-bw / 2 - 4} y={-bh / 2 - 4} width={bw + 8} height={bh + 8} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
|
||
<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="network-map-focusable"
|
||
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 className="network-map-focus-ring" x={-bw / 2 - 4} y={-bh / 2 - 4} width={bw + 8} height={bh + 8} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
|
||
<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.country?.toUpperCase() || "—"}`,
|
||
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({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onPointerDown }: {
|
||
n: Server & { x: number; y: number }
|
||
labelScale?: number
|
||
isSel: boolean; isVis: boolean; isDragged: boolean
|
||
/** Не дублировать «N мс» из каталога, если на карте уже показаны метрики GRE (оранжевые числа в круге). */
|
||
hideCatalogLatency?: boolean
|
||
onClick: () => void
|
||
onDblClick: () => void
|
||
onHoverChange: (hov: boolean) => void
|
||
onContextMenu: (e: React.MouseEvent) => void
|
||
onPointerDown: (e: React.PointerEvent<SVGGElement>) => void
|
||
}) {
|
||
const ss = STATUS_STYLE[n.status]
|
||
const ts = TYPE_STYLE[n.type]
|
||
|
||
return (
|
||
<g className="network-map-focusable" 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}
|
||
onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }}
|
||
onClick={e => { e.stopPropagation(); onClick() }}
|
||
onDoubleClick={e => { e.stopPropagation(); onDblClick() }}
|
||
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); onContextMenu(e) }}
|
||
onMouseEnter={() => onHoverChange(true)}
|
||
onMouseLeave={() => onHoverChange(false)}
|
||
>
|
||
<circle className="network-map-focus-ring" r={ts.r + 6} fill="none" stroke="var(--ring)" strokeWidth="2" />
|
||
{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" opacity="0.45" />
|
||
)}
|
||
<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 || n.country?.toUpperCase() || "—"}
|
||
</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 || n.country?.toUpperCase() || "—"}</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={ts.r + 18} fontSize={Math.max(8.5, 11 * labelScale)} fill="var(--muted-foreground)"
|
||
fontFamily="ui-monospace,monospace">{shortName(n.name,labelScale>1?12:24)}</text>
|
||
))}
|
||
</g>
|
||
)
|
||
}
|
||
|
||
function ServiceNode({
|
||
label,
|
||
share,
|
||
x,
|
||
y,
|
||
isSel,
|
||
isVis,
|
||
isDragged,
|
||
destMode,
|
||
iso,
|
||
dim,
|
||
shareLabel,
|
||
onClick,
|
||
onPointerDown,
|
||
}: {
|
||
label: string
|
||
share: number
|
||
x: number
|
||
y: number
|
||
isSel: boolean
|
||
isVis: boolean
|
||
isDragged: boolean
|
||
destMode: DestMode
|
||
iso?: string
|
||
/** Приглушение узла при раскрытии другой страны (остаётся на холсте). */
|
||
dim?: boolean
|
||
/** Подпись доли в tooltip: у вложенных сервисов — доля страны, не окна. */
|
||
shareLabel?: string
|
||
onClick: () => void
|
||
onPointerDown: (e: React.PointerEvent<SVGGElement>) => 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="network-map-focusable"
|
||
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}
|
||
onPointerDown={(e) => { e.stopPropagation(); onPointerDown(e) }}
|
||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||
>
|
||
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
|
||
<rect className="network-map-focus-ring" x={-bw / 2 - 3} y={-bh / 2 - 3} width={bw + 6} height={bh + 6} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
|
||
{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, onPointerDown }: {
|
||
x: number; y: number
|
||
wan: { name: string; isp: string; maxDl: number; maxUl: number }
|
||
color: string; active: boolean; isSel: boolean; isDragged: boolean
|
||
onSelect: () => void
|
||
onPointerDown: (e: React.PointerEvent<SVGGElement>) => void
|
||
}) {
|
||
const r = 18
|
||
return (
|
||
<g role="button" aria-label={"Аплинк "+wan.name} aria-pressed={isSel} tabIndex={0} className="network-map-focusable"
|
||
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}}
|
||
transform={`translate(${x},${y})`}
|
||
style={{ cursor: isDragged ? "grabbing" : "grab" }}
|
||
onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }}
|
||
onClick={e => { e.stopPropagation(); onSelect() }}>
|
||
<circle className="network-map-focus-ring" r={r + 4} fill="none" stroke="var(--ring)" strokeWidth="2" />
|
||
{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({ viewportHeight, pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, servicePos, onClose, onPan }: {
|
||
viewportHeight: number
|
||
pan: { x: number; y: number }; zoom: number
|
||
nodes: (Server & { x: number; y: number })[]
|
||
greEdges: GreMapEdge[]
|
||
satPos: Record<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, viewportHeight / 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={viewportHeight / 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 [viewportHeight,setViewportHeight]=useState(H)
|
||
const [canvasMeasured,setCanvasMeasured]=useState(false)
|
||
const [canvasWidth,setCanvasWidth]=useState(W)
|
||
const [isDragging, setIsDragging] = useState(false)
|
||
|
||
// ── Interaction ─────────────────────────────────────────────────────────────
|
||
const selected=urlState.server?mapServers.find(server=>server.id===urlState.server)??null:null
|
||
const setSelected=useCallback((value:React.SetStateAction<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)
|
||
const canvasRef=useRef<HTMLDivElement>(null)
|
||
const initialFit=useRef(false)
|
||
useEffect(()=>{const element=canvasRef.current;if(!element)return;const observer=new ResizeObserver(entries=>{const {width,height}=entries[0].contentRect;if(width<=0||height<=0)return;setViewportHeight(W*height/width);setCanvasWidth(width);setCanvasMeasured(true)});observer.observe(element);return()=>observer.disconnect()},[])
|
||
|
||
// Canvas pan drag
|
||
const dragRef = useRef<{
|
||
startX: number; startY: number
|
||
panX: number; panY: number
|
||
moved: boolean
|
||
} | null>(null)
|
||
|
||
// Node / WAN-sat drag
|
||
type NodeDrag =
|
||
| { kind: "server"; nodeId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean }
|
||
| { kind: "wan-sat"; homeId: string; wanIdx: number; startX: number; startY: number; origX: number; origY: number; moved: boolean }
|
||
| { kind: "service"; svcId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean }
|
||
const nodeDragRef = useRef<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.querySelectorAll(".network-map-focus-ring").forEach(ring => ring.remove())
|
||
clone.setAttribute("viewBox", `0 0 ${W} ${H}`)
|
||
clone.setAttribute("width", String(W))
|
||
clone.setAttribute("height", String(H))
|
||
clone.setAttribute("xmlns","http://www.w3.org/2000/svg")
|
||
const computed=getComputedStyle(svg)
|
||
const variables=new Set([...clone.outerHTML.matchAll(/var\((--[a-zA-Z0-9_-]+)/g)].map(match=>match[1]))
|
||
variables.add("--background")
|
||
for(const name of variables){const value=computed.getPropertyValue(name).trim();if(!value)continue;clone.style.setProperty(name,value);for(const match of value.matchAll(/var\((--[a-zA-Z0-9_-]+)/g))variables.add(match[1])}
|
||
clone.style.width=W+"px";clone.style.height=H+"px"
|
||
const background=document.createElementNS("http://www.w3.org/2000/svg","rect")
|
||
background.setAttribute("width",String(W));background.setAttribute("height",String(H));background.setAttribute("fill","var(--background)");clone.prepend(background)
|
||
const blob = new Blob(
|
||
[`<?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 * ((W * rect.height / rect.width) / z)
|
||
// trackpad pinch sends ctrlKey, use smaller step
|
||
const step = e.ctrlKey ? 1.06 : 1.14
|
||
const factor = e.deltaY < 0 ? step : 1 / step
|
||
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z * factor))
|
||
setPan({
|
||
x: svgX - (e.clientX - rect.left) / rect.width * (W / newZoom),
|
||
y: svgY - (e.clientY - rect.top) / rect.height * ((W * rect.height / rect.width) / newZoom),
|
||
})
|
||
setZoom(newZoom)
|
||
}
|
||
svg.addEventListener("wheel", handler, { passive: false })
|
||
return () => svg.removeEventListener("wheel", handler)
|
||
}, [])
|
||
|
||
const returnToMapControl=useCallback(()=>{queueMicrotask(()=>document.querySelector<HTMLSelectElement>("[data-map-device-select]")?.focus())},[])
|
||
|
||
// ── Zoom helpers ──────────────────────────────────────────────────────────
|
||
const applyZoomCenter=useCallback((factor: number) => {
|
||
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
|
||
setPan(p => ({
|
||
x: p.x + (W / zoom - W / newZoom) / 2,
|
||
y: p.y + (viewportHeight / zoom - viewportHeight / newZoom) / 2,
|
||
}))
|
||
setZoom(newZoom)
|
||
},[zoom,viewportHeight])
|
||
|
||
const {pan:{x:fitX,y:fitY},zoom:fitZoom}=fitNetworkMapCamera([...Object.values(nodePosById),...Object.values(effectiveSatPos).flat(),...Object.values(servicePosById),...Object.values(nestedPosById)],W,viewportHeight)
|
||
function fitView(){setPan({x:fitX,y:fitY});setZoom(fitZoom)}
|
||
useEffect(()=>{if(initialFit.current||!canvasMeasured||!mapServers.length)return;initialFit.current=true;queueMicrotask(()=>{setPan({x:fitX,y:fitY});setZoom(fitZoom)})},[canvasMeasured,mapServers.length,fitX,fitY,fitZoom])
|
||
|
||
function focusNode(x: number, y: number) {
|
||
const newZoom = Math.min(ZOOM_MAX, Math.max(zoom * 1.8, 2))
|
||
setPan({ x: x - W / (2 * newZoom), y: y - viewportHeight / (2 * newZoom) })
|
||
setZoom(newZoom)
|
||
}
|
||
|
||
// ── Keyboard shortcuts ────────────────────────────────────────────────────
|
||
useEffect(() => {
|
||
function onKey(e: KeyboardEvent) {
|
||
if (ctxMenu||showLayers) return
|
||
if (e.key!=="Escape"&&(e.ctrlKey||e.metaKey||e.altKey||e.target instanceof HTMLInputElement||e.target instanceof HTMLTextAreaElement||e.target instanceof HTMLSelectElement))return
|
||
if (e.key === "Escape") {
|
||
setSelected(null)
|
||
updateUrl({wan:null})
|
||
setSelectedGreEdge(null)
|
||
setSelectedService(null)
|
||
setHighlightedPath(null)
|
||
setExpandedCountryId(null)
|
||
returnToMapControl()
|
||
}
|
||
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
|
||
if (e.key === "-") applyZoomCenter(1 / 1.25)
|
||
if (e.key === "0" || e.key.toLowerCase() === "f") {setPan({x:fitX,y:fitY});setZoom(fitZoom)}
|
||
if (e.key.toLowerCase() === "m") setShowMinimap(v => !v)
|
||
if (e.key.toLowerCase() === "p") setShowPingBadges(v => !v)
|
||
}
|
||
window.addEventListener("keydown", onKey)
|
||
return () => window.removeEventListener("keydown", onKey)
|
||
}, [applyZoomCenter,fitX,fitY,fitZoom,setSelected,updateUrl,ctxMenu,showLayers,returnToMapControl])
|
||
|
||
// ── Drag ─────────────────────────────────────────────────────────────────
|
||
function onSvgPointerDown(e: React.PointerEvent<SVGSVGElement>) {
|
||
if(!e.isPrimary||e.button!==0)return
|
||
if(e.target!==e.currentTarget&&!(e.target instanceof Element&&e.target.hasAttribute("data-map-background")))return
|
||
e.currentTarget.setPointerCapture(e.pointerId)
|
||
suppressClickRef.current=false
|
||
// Only pan on background clicks (node mousedown stops propagation)
|
||
dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y, moved: false }
|
||
setIsDragging(true)
|
||
}
|
||
|
||
function onSvgPointerMove(e: React.PointerEvent<SVGSVGElement>) {
|
||
if(!e.isPrimary)return
|
||
const nd = nodeDragRef.current
|
||
if (nd) {
|
||
const dx = e.clientX - nd.startX
|
||
const dy = e.clientY - nd.startY
|
||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) nd.moved = true
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
const svgDx = dx * (W / zoom) / rect.width
|
||
const svgDy = dy * (viewportHeight / zoom) / rect.height
|
||
const nx = nd.origX + svgDx
|
||
const ny = nd.origY + svgDy
|
||
|
||
if (nd.kind === "server") {
|
||
setNodePositions(prev => ({ ...prev, [nd.nodeId]: { x: nx, y: ny } }))
|
||
} else if (nd.kind === "service") {
|
||
setServicePositions(prev => ({ ...prev, [nd.svcId]: { x: nx, y: ny } }))
|
||
} else {
|
||
setSatPositions(prev => {
|
||
const arr = [...(prev[nd.homeId] ?? (autoLayout.wanSatPos[nd.homeId] ?? []))]
|
||
arr[nd.wanIdx] = { x: nx, y: ny }
|
||
return { ...prev, [nd.homeId]: arr }
|
||
})
|
||
}
|
||
return
|
||
}
|
||
|
||
if (!dragRef.current) return
|
||
const dx = e.clientX - dragRef.current.startX
|
||
const dy = e.clientY - dragRef.current.startY
|
||
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragRef.current.moved = true
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
setPan({
|
||
x: dragRef.current.panX - dx * (W / zoom / rect.width),
|
||
y: dragRef.current.panY - dy * (viewportHeight / zoom / rect.height),
|
||
})
|
||
}
|
||
|
||
function finishNodeDrag() {
|
||
if (!nodeDragRef.current) return false
|
||
suppressClickRef.current = nodeDragRef.current.moved
|
||
nodeDragRef.current = null
|
||
setDraggedNodeId(null)
|
||
setDraggedSatKey(null)
|
||
setDraggedSvcId(null)
|
||
return true
|
||
}
|
||
|
||
function onSvgPointerUp(e:React.PointerEvent<SVGSVGElement>) {
|
||
if(!e.isPrimary||e.button!==0)return
|
||
if (finishNodeDrag()) return
|
||
if(!dragRef.current)return
|
||
const moved = dragRef.current?.moved ?? false
|
||
dragRef.current = null
|
||
setIsDragging(false)
|
||
if (!moved) {
|
||
setSelected(null)
|
||
setSelWanIdx(null)
|
||
setSelectedGreEdge(null)
|
||
setSelectedService(null)
|
||
setHighlightedPath(null)
|
||
setExpandedCountryId(null)
|
||
}
|
||
}
|
||
|
||
// ── Node drag start ──────────────────────────────────────────────────────
|
||
function onNodePointerDown(e: React.PointerEvent<SVGGElement>, n: Server & {x: number; y: number}) {
|
||
if(!e.isPrimary||e.button!==0)return
|
||
e.currentTarget.setPointerCapture(e.pointerId)
|
||
suppressClickRef.current=false
|
||
nodeDragRef.current = { kind: "server", nodeId: n.id, startX: e.clientX, startY: e.clientY, origX: n.x, origY: n.y, moved: false }
|
||
setDraggedNodeId(n.id)
|
||
}
|
||
function onSatPointerDown(e: React.PointerEvent<SVGGElement>, homeId: string, wanIdx: number, x: number, y: number) {
|
||
if(!e.isPrimary||e.button!==0)return
|
||
e.currentTarget.setPointerCapture(e.pointerId)
|
||
suppressClickRef.current=false
|
||
nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
|
||
setDraggedSatKey(`${homeId}-${wanIdx}`)
|
||
}
|
||
function onServicePointerDown(e: React.PointerEvent<SVGGElement>, svcId: string, x: number, y: number) {
|
||
if(!e.isPrimary||e.button!==0)return
|
||
e.currentTarget.setPointerCapture(e.pointerId)
|
||
suppressClickRef.current=false
|
||
nodeDragRef.current = { kind: "service", svcId, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
|
||
setDraggedSvcId(svcId)
|
||
}
|
||
|
||
// ── Visibility / search ──────────────────────────────────────────────────
|
||
const sq = search.toLowerCase().trim()
|
||
const matchesSearch = (n: typeof nodes[0]) =>
|
||
!sq ||
|
||
n.name.toLowerCase().includes(sq) ||
|
||
n.site.toLowerCase().includes(sq) ||
|
||
n.model?.toLowerCase().includes(sq) ||
|
||
n.os?.toLowerCase().includes(sq)
|
||
|
||
const isVisible = (n: typeof nodes[0]) => {
|
||
if (!matchesSearch(n)) return false
|
||
if (filter === "all") return true
|
||
if (filter === "online" || filter === "degraded" || filter === "offline" || filter === "unknown") return n.status === filter
|
||
return n.type === filter
|
||
}
|
||
|
||
// ── Side panel ────────────────────────────────────────────────────────────
|
||
useEffect(() => {
|
||
if (
|
||
expandedCountryId
|
||
&& (!mapCountries.some((s) => s.id === expandedCountryId)
|
||
|| !mapCountryServiceGroups.some((g) => g.countryId === expandedCountryId))
|
||
) {
|
||
queueMicrotask(() => setExpandedCountryId(null))
|
||
}
|
||
}, [mapCountries, mapCountryServiceGroups, expandedCountryId])
|
||
useEffect(() => {
|
||
if (!selectedService) return
|
||
const stillVisible =
|
||
destNodes.some((s) => s.id === selectedService.id)
|
||
|| nestedServices.some((s) => s.id === selectedService.id)
|
||
if (!stillVisible) {
|
||
queueMicrotask(() => {
|
||
setSelectedService(null)
|
||
setHighlightedPath(null)
|
||
})
|
||
}
|
||
}, [destMode, destNodes, nestedServices, selectedService])
|
||
useEffect(() => {
|
||
if (!showServices) queueMicrotask(() => setExpandedCountryId(null))
|
||
}, [showServices])
|
||
// Сдвиг колонки стран меняет систему координат: сбрасываем drag-овчины сервисов.
|
||
useEffect(() => {
|
||
queueMicrotask(() => setServicePositions({}))
|
||
}, [expandedCountryId])
|
||
function selectServer(s: Server) {
|
||
setSelectedGreEdge(null)
|
||
setSelectedService(null)
|
||
setHighlightedPath(null)
|
||
setSelected(prev => prev?.id === s.id ? null : s)
|
||
setSelWanIdx(null)
|
||
setHoveredId(null)
|
||
}
|
||
function selectService(svc: FlowMapService) {
|
||
setSelectedGreEdge(null)
|
||
setSelected(null)
|
||
setSelWanIdx(null)
|
||
setHighlightedPath(null)
|
||
setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc)
|
||
// Клик по стране в режиме «Страны» раскрывает столбец её сервисов; повторный — сворачивает.
|
||
if (destMode === "countries" && svc.id.startsWith("cc:") && !svc.id.includes("|")) {
|
||
setExpandedCountryId((prev) => (prev === svc.id ? null : svc.id))
|
||
}
|
||
}
|
||
function selectWan(s: Server, wanIdx: number) {
|
||
setSelectedGreEdge(null)
|
||
setSelectedService(null)
|
||
setHighlightedPath(null)
|
||
setSelected(s)
|
||
setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx)
|
||
}
|
||
|
||
function togglePathHighlight(p: FlowMapServicePath) {
|
||
setHighlightedPath((prev) => (
|
||
prev
|
||
&& prev.viaId === p.viaId
|
||
&& prev.enId === p.enId
|
||
&& prev.serviceId === p.serviceId
|
||
? null
|
||
: { viaId: p.viaId, enId: p.enId, serviceId: p.serviceId }
|
||
))
|
||
}
|
||
|
||
function openWanJhSpeedDetail(ev: React.SyntheticEvent<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} disabled={useLiveData&&(!hasSnapshot||!mapServers.length)}>
|
||
<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 ref={canvasRef} className={cn("flex-1 min-h-0 min-w-0 relative overflow-hidden bg-background select-none", (selected || selectedGreEdge || selectedService) && "hidden lg:block")}>
|
||
<NetworkMapLegend />
|
||
<p id="network-map-help" className="sr-only">Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.</p>
|
||
<svg
|
||
ref={svgRef} role="group" aria-label="Интерактивная карта сети" tabIndex={0} aria-describedby="network-map-help"
|
||
className="focus-visible:outline-2 focus-visible:outline-ring"
|
||
onKeyDown={e=>{if(e.target!==e.currentTarget||e.ctrlKey||e.metaKey||e.altKey)return;const delta=40/zoom;if(e.key==="ArrowLeft"||e.key==="ArrowRight"||e.key==="ArrowUp"||e.key==="ArrowDown"){e.preventDefault();e.stopPropagation();setPan(value=>({x:value.x+(e.key==="ArrowLeft"?-delta:e.key==="ArrowRight"?delta:0),y:value.y+(e.key==="ArrowUp"?-delta:e.key==="ArrowDown"?delta:0)}))}}}
|
||
viewBox={`${pan.x} ${pan.y} ${W / zoom} ${viewportHeight / zoom}`}
|
||
style={{ width: "100%", height: "100%", display: "block", cursor: isDragging ? "grabbing" : "grab", userSelect: "none", WebkitUserSelect: "none", touchAction:"none" }}
|
||
onPointerDown={onSvgPointerDown}
|
||
onPointerMove={onSvgPointerMove}
|
||
onPointerUp={onSvgPointerUp}
|
||
onPointerLeave={()=>setHoveredId(null)}
|
||
onPointerCancel={()=>{finishNodeDrag();suppressClickRef.current=false;dragRef.current=null;setIsDragging(false)}}
|
||
onLostPointerCapture={()=>{finishNodeDrag();dragRef.current=null;setIsDragging(false)}}
|
||
>
|
||
<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 data-map-background x={pan.x} y={pan.y} width={W/zoom} height={viewportHeight/zoom} 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 labelScale={W/(canvasWidth*zoom)}
|
||
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 })}
|
||
onPointerDown={e => onNodePointerDown(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)
|
||
}}
|
||
onPointerDown={e => onSatPointerDown(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}
|
||
onPointerDown={(e) => onServicePointerDown(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="трафика страны"
|
||
onPointerDown={(e) => onServicePointerDown(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} />
|
||
)}
|
||
|
||
{/* ── Keyboard hints (viewport-fixed) ── */}
|
||
{showHints && (
|
||
<g transform={`translate(${pan.x + W / zoom - 14}, ${pan.y + viewportHeight / 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>
|
||
|
||
{useLiveData&&!hasSnapshot&&dataLoading&&<p role="status" className="absolute inset-0 flex items-center justify-center bg-background/80 text-sm">Загружаем карту…</p>}
|
||
{(hasSnapshot||!useLiveData)&&!nodes.length&&<div role="status" className="absolute inset-0 flex flex-col gap-2 items-center justify-center bg-background/90 p-4 text-center"><p className="font-medium">Устройств на карте пока нет</p><p className="text-sm text-muted-foreground">В текущем источнике нет устройств для построения схемы.</p><Link href="/servers" className="text-sm underline">Открыть серверы</Link></div>}
|
||
{!!nodes.length&&!nodes.some(isVisible)&&<div role="status" className="absolute left-3 right-3 top-16 rounded-md border bg-popover p-3 text-sm"><p>Узлы по заданным условиям не найдены.</p><Button type="button" variant="outline" size="sm" className="mt-2" onClick={()=>updateUrl({q:null,filter:null})}>Сбросить срез карты</Button></div>}
|
||
|
||
{/* ── Zoom controls ── */}
|
||
<ZoomControls
|
||
zoom={zoom}
|
||
onZoomIn={() => applyZoomCenter(1.3)}
|
||
onZoomOut={() => applyZoomCenter(1 / 1.3)}
|
||
onFit={fitView}
|
||
/>
|
||
|
||
{/* ── Minimap ── */}
|
||
{showMinimap && (
|
||
<Minimap viewportHeight={viewportHeight}
|
||
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);returnToMapControl()}}
|
||
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);returnToMapControl()}}
|
||
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); returnToMapControl() }}
|
||
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} role="button" tabIndex={0} aria-label={"WAN "+wan.name} aria-pressed={isSel}
|
||
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();setSelWanIdx(prev=>prev===wIdx?null:wIdx)}}}
|
||
onClick={() => setSelWanIdx(prev => prev === wIdx ? null : wIdx)}
|
||
className={cn(
|
||
"rounded-lg border p-3 cursor-pointer transition-all",
|
||
isSel ? "bg-muted/60" : "bg-muted/20 hover:bg-muted/40",
|
||
)}
|
||
style={{ borderColor: isSel ? color : undefined }}>
|
||
<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&¤tCtxServer&&<ContextMenu menu={{...ctxMenu,server:currentCtxServer}} onClose={()=>setCtxMenu(null)} />}
|
||
</div>
|
||
)
|
||
}
|