Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m39s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2395 lines
107 KiB
TypeScript
2395 lines
107 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useRef, useEffect, useMemo, useCallback } from "react"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import {
|
||
servers as mockServers,
|
||
greTunnels as mockGreTunnels,
|
||
type GreTunnel,
|
||
type Server,
|
||
type ServerStatus,
|
||
type ServerType,
|
||
} from "@/lib/data"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
import { requestJson } from "@/shared/api/http-client"
|
||
import {
|
||
buildGreMapEdges,
|
||
buildServerResourceMap,
|
||
buildWanJhEdges,
|
||
computeNetworkMapLayout,
|
||
NETWORK_MAP_LAYOUT_REVISION,
|
||
NETWORK_MAP_PIPELINE_Y,
|
||
findServerByGreRemote,
|
||
greSourceWanIndexOnMap,
|
||
greTunnelProbe,
|
||
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 { Button } from "@/components/ui/button"
|
||
import { StatusBadge } from "@/components/status-badge"
|
||
import { StatusDot } from "@/components/status-dot"
|
||
import {
|
||
DownloadIcon, XIcon, LockIcon, LockOpenIcon, WifiIcon, RefreshCwIcon,
|
||
ZoomInIcon, ZoomOutIcon, Maximize2Icon, SearchIcon,
|
||
LayersIcon, MapIcon, HomeIcon, TerminalIcon, ShieldIcon,
|
||
CableIcon, CopyIcon, ActivityIcon, ExternalLinkIcon,
|
||
} from "lucide-react"
|
||
import { cn } from "@/lib/utils"
|
||
import Link from "next/link"
|
||
import { Flag } from "@/components/flag"
|
||
|
||
// ─── Resource metrics (для мини-блока справа; числа детерминированы по id узла) ─
|
||
|
||
// ─── Backend → frontend (как /servers) ───────────────────────────────────────
|
||
|
||
interface BackendServerRow {
|
||
id: number
|
||
name: string
|
||
host: string
|
||
port: number
|
||
useSsl: boolean
|
||
verifySsl: boolean
|
||
username: string
|
||
password: string
|
||
type: ServerType
|
||
site: string
|
||
country: string
|
||
asn: string
|
||
comment: string
|
||
enabled: boolean
|
||
lanSubnet?: string
|
||
wanUplinks?: Array<{
|
||
id: string
|
||
name: string
|
||
isp: string
|
||
iface: string
|
||
ip: string
|
||
maxDl: number
|
||
maxUl: number
|
||
}>
|
||
status: "online" | "offline" | null
|
||
latency: number | null
|
||
os: string | null
|
||
model: string | null
|
||
sessions: number
|
||
/** Снапшот последнего poll (как в разделе «Серверы») */
|
||
uptime?: string | null
|
||
cpuLoad?: number | null
|
||
freeMemory?: number | null
|
||
totalMemory?: number | null
|
||
polledAt?: string | null
|
||
createdAt: string
|
||
updatedAt: string
|
||
}
|
||
|
||
function mapBackendRowToServer(s: BackendServerRow): Server {
|
||
return {
|
||
id: String(s.id),
|
||
name: s.name || s.host,
|
||
host: s.host,
|
||
type: s.type,
|
||
site: s.site,
|
||
country: s.country,
|
||
asn: s.asn,
|
||
model: s.model ?? "—",
|
||
os: s.os ?? "—",
|
||
enabled: s.enabled,
|
||
status: (s.status === null ? "offline" : s.status) as Server["status"],
|
||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||
sessions: s.sessions ?? 0,
|
||
comment: s.comment || undefined,
|
||
lanSubnet: s.lanSubnet || undefined,
|
||
wanUplinks: Array.isArray(s.wanUplinks) && s.wanUplinks.length ? s.wanUplinks : undefined,
|
||
uptime: s.uptime ?? undefined,
|
||
cpuLoad: s.cpuLoad ?? undefined,
|
||
freeMemory: s.freeMemory ?? undefined,
|
||
totalMemory: s.totalMemory ?? undefined,
|
||
polledAt: s.polledAt ?? undefined,
|
||
}
|
||
}
|
||
|
||
interface ApiGreTunnelRow {
|
||
id: string
|
||
name: string
|
||
serverId: string
|
||
localAddress: string
|
||
remoteAddress: string
|
||
localInnerIp: string
|
||
remoteInnerIp: string
|
||
poolId: string
|
||
ipsec: null
|
||
mtu: number
|
||
keepaliveInterval: number
|
||
keepaliveRetries: number
|
||
dscp: "inherit" | number
|
||
clampTcpMss: boolean
|
||
allowFastPath: boolean
|
||
comment: string
|
||
enabled: boolean
|
||
status: "up" | "down" | "degraded"
|
||
}
|
||
|
||
function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel {
|
||
return {
|
||
id: t.id,
|
||
name: t.name,
|
||
serverId: String(t.serverId),
|
||
localAddress: t.localAddress,
|
||
remoteAddress: t.remoteAddress,
|
||
localInnerIp: t.localInnerIp,
|
||
remoteInnerIp: t.remoteInnerIp,
|
||
poolId: t.poolId || "live",
|
||
ipsec: null,
|
||
mtu: t.mtu,
|
||
keepaliveInterval: t.keepaliveInterval,
|
||
keepaliveRetries: t.keepaliveRetries,
|
||
dscp: t.dscp,
|
||
clampTcpMss: t.clampTcpMss,
|
||
allowFastPath: t.allowFastPath,
|
||
comment: t.comment,
|
||
enabled: t.enabled,
|
||
status: t.status,
|
||
}
|
||
}
|
||
|
||
function makeApiFetch(backendUrl: string) {
|
||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||
return requestJson<T>(backendUrl, path, init)
|
||
}
|
||
}
|
||
|
||
function fmtMB(mb: number): string {
|
||
if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ`
|
||
return `${mb} МБ`
|
||
}
|
||
function fmtUptime(sec: number): string {
|
||
const d = Math.floor(sec / 86400)
|
||
const h = Math.floor((sec % 86400) / 3600)
|
||
const m = Math.floor((sec % 3600) / 60)
|
||
if (d > 0) return `${d}д ${h}ч`
|
||
if (h > 0) return `${h}ч ${m}м`
|
||
return `${m}м`
|
||
}
|
||
function resBarColor(pct: number): string {
|
||
if (pct >= 85) return "bg-red-500"
|
||
if (pct >= 70) return "bg-amber-500"
|
||
return "bg-emerald-500"
|
||
}
|
||
function resPctColor(pct: number): string {
|
||
if (pct >= 85) return "text-red-400"
|
||
if (pct >= 70) return "text-amber-400"
|
||
return "text-emerald-400"
|
||
}
|
||
|
||
function MiniBar({ pct }: { pct: number }) {
|
||
return (
|
||
<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 = 1060
|
||
const H = 580
|
||
const ZOOM_MIN = 0.2
|
||
const ZOOM_MAX = 6
|
||
|
||
// ─── Visual config ────────────────────────────────────────────────────────────
|
||
|
||
const STATUS_STYLE = {
|
||
online: { fill: "#0f2d1f", stroke: "#4ade80", text: "#4ade80", glow: "rgba(74,222,128,0.15)" },
|
||
degraded: { fill: "#2d1e06", stroke: "#fbbf24", text: "#fbbf24", glow: "rgba(251,191,36,0.15)" },
|
||
offline: { fill: "#2d0f0f", stroke: "#f87171", text: "#f87171", glow: "transparent" },
|
||
}
|
||
|
||
const TYPE_STYLE: Record<ServerType, { label: string; fill: string; r: number }> = {
|
||
// HR — визуальный корень топологии: крупнее и с более сильным акцентом.
|
||
"home-router": { label: "HR", fill: "#16a34a", r: 40 },
|
||
// JH — агрегирующий слой по центру.
|
||
"jump-host": { label: "JH", fill: "#7c3aed", r: 34 },
|
||
// EN — крайние edge-узлы, менее акцентные.
|
||
"exit-node": { label: "EN", fill: "#0369a1", r: 30 },
|
||
}
|
||
|
||
const WAN_COLORS = ["#0ea5e9", "#f97316", "#a855f7", "#ec4899", "#14b8a6"]
|
||
|
||
const TUNNEL_STYLE = {
|
||
up: { stroke: "#4ade80", opacity: 0.5, dotOpacity: 0.85 },
|
||
degraded: { stroke: "#fbbf24", opacity: 0.4, dotOpacity: 0.7 },
|
||
down: { stroke: "#f87171", opacity: 0.15, dotOpacity: 0 },
|
||
}
|
||
|
||
const TYPE_LABELS: Record<ServerType, string> = {
|
||
"jump-host": "JumpHost",
|
||
"exit-node": "Exit Node",
|
||
"home-router": "Home Router",
|
||
}
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function pingColor(ms: number | null) {
|
||
if (ms == null) return "#f87171"
|
||
if (ms < 15) return "#4ade80"
|
||
if (ms < 50) return "#fbbf24"
|
||
return "#fb923c"
|
||
}
|
||
function latencyColor(ms: number | null) {
|
||
if (ms == null) return "#f87171"
|
||
if (ms > 60) return "#fbbf24"
|
||
return "#4ade80"
|
||
}
|
||
function shortName(name: string) {
|
||
return name.replace(/^(home|mt)-/, "").split("-").slice(0, 3).join("-")
|
||
}
|
||
|
||
function greStatusToServerStatus(s: "up" | "down" | "degraded"): ServerStatus {
|
||
if (s === "up") return "online"
|
||
if (s === "degraded") return "degraded"
|
||
return "offline"
|
||
}
|
||
|
||
/**
|
||
* Позиция бейджа на отрезке: не только середина, а доля t + сдвиг по нормали.
|
||
* GRE и PingBadge (WAN→JH) используют разные t и знак нормали — меньше наложений.
|
||
*/
|
||
function edgeBadgePosition(
|
||
x1: number,
|
||
y1: number,
|
||
x2: number,
|
||
y2: number,
|
||
t: number,
|
||
normalPx: number,
|
||
): { mx: number; my: number } {
|
||
const tc = Math.min(0.82, Math.max(0.18, t))
|
||
const px = x1 + (x2 - x1) * tc
|
||
const py = y1 + (y2 - y1) * tc
|
||
const dx = x2 - x1
|
||
const dy = y2 - y1
|
||
const len = Math.hypot(dx, dy) || 1
|
||
const nx = -dy / len
|
||
const ny = dx / len
|
||
return { mx: px + nx * normalPx, my: py + ny * normalPx }
|
||
}
|
||
|
||
// ─── Filter ───────────────────────────────────────────────────────────────────
|
||
|
||
type FilterKey = "all" | "online" | "degraded" | "offline" | "jump-host" | "exit-node" | "home-router"
|
||
|
||
function greEdgeKey(e: GreMapEdge): string {
|
||
const la = (e.tunnel.localAddress ?? "").trim()
|
||
const ra = (e.tunnel.remoteAddress ?? "").trim()
|
||
return `${e.fromServer.id}|${e.toServer.id}|${e.tunnel.id}|${la}|${ra}`
|
||
}
|
||
|
||
// ─── SVG sub-components ───────────────────────────────────────────────────────
|
||
|
||
/** Бейдж на ребре WAN → JumpHost (не GRE): сумма latency домашнего узла и JH — те же поля, что в разделе Серверы. */
|
||
function PingBadge({ mx, my, ping, dl, ul, color }: {
|
||
mx: number; my: number
|
||
ping: number | null; dl?: number | null; ul?: number | null
|
||
color: string
|
||
}) {
|
||
const hasSpeed = dl != null && dl > 0
|
||
const h = hasSpeed ? 36 : 20
|
||
return (
|
||
<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="#060d1a" 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="#64748b" fontFamily="ui-monospace,monospace">
|
||
{`↓${dl} ↑${ul}`}
|
||
</text>
|
||
)}
|
||
</g>
|
||
)
|
||
}
|
||
|
||
/** Метрики на GRE-ребре: компактно; подробности — в tooltip (`title`). */
|
||
function GreEdgeMetricBadge({
|
||
mx,
|
||
my,
|
||
pingMs,
|
||
dl,
|
||
ul,
|
||
onOpen,
|
||
rttFromMonitor,
|
||
throughputFromMonitor,
|
||
outerSummary,
|
||
}: {
|
||
mx: number
|
||
my: number
|
||
pingMs: number | null
|
||
dl: number | null
|
||
ul: number | null
|
||
onOpen: (e: React.MouseEvent<SVGElement>) => void
|
||
/** Данные из Мониторинг → скорость (uptime speed-probes) */
|
||
rttFromMonitor?: boolean
|
||
throughputFromMonitor?: boolean
|
||
/** Внешние IP GRE и WAN — только в подсказке при наведении */
|
||
outerSummary?: string
|
||
}) {
|
||
const hasSpeed =
|
||
dl != null &&
|
||
ul != null &&
|
||
(throughputFromMonitor || dl > 0 || ul > 0)
|
||
const bw = 62
|
||
const bh = 24 + (hasSpeed ? 14 : 0)
|
||
const v1 = pingMs == null ? "—" : `${pingMs} мс`
|
||
const hint =
|
||
(outerSummary ? `${outerSummary}. ` : "") +
|
||
(rttFromMonitor
|
||
? "RTT — ping/BT из «Мониторинг → скорость». "
|
||
: "RTT — модель по данным туннеля. ") +
|
||
(throughputFromMonitor ? "Строка ниже — TX/RX с пробы. " : "") +
|
||
"Клик — панель справа."
|
||
return (
|
||
<g
|
||
transform={`translate(${mx},${my})`}
|
||
style={{ cursor: "pointer" }}
|
||
onPointerDown={(e) => { e.stopPropagation() }}
|
||
onClick={(e) => { e.stopPropagation(); onOpen(e) }}
|
||
>
|
||
<title>{hint}</title>
|
||
<rect
|
||
x={-bw / 2}
|
||
y={-bh / 2}
|
||
width={bw}
|
||
height={bh}
|
||
rx="6"
|
||
fill="rgba(6,13,26,0.94)"
|
||
stroke="#fbbf24"
|
||
strokeWidth="1.15"
|
||
/>
|
||
<text
|
||
textAnchor="middle"
|
||
y={hasSpeed ? "-5" : "4"}
|
||
fontFamily="ui-monospace,monospace"
|
||
>
|
||
<tspan fill="#fcd34d" fontSize="9.5" fontWeight="700">{v1}</tspan>
|
||
</text>
|
||
{hasSpeed && (
|
||
<text textAnchor="middle" y="11" fontFamily="ui-monospace,monospace">
|
||
<tspan fill="#38bdf8" fontSize="7.5" fontWeight="600">{`↓${dl} ↑${ul}`}</tspan>
|
||
</text>
|
||
)}
|
||
</g>
|
||
)
|
||
}
|
||
|
||
function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) {
|
||
const ss = STATUS_STYLE[n.status]
|
||
const ts = TYPE_STYLE[n.type]
|
||
const lc = latencyColor(n.latency)
|
||
const ox = n.x + ts.r + 14
|
||
const oy = n.y - 32
|
||
const lines = [
|
||
n.name,
|
||
`${TYPE_LABELS[n.type]} · ${n.site}`,
|
||
n.latency != null ? `${n.latency} мс` : "Офлайн",
|
||
n.model ?? "",
|
||
].filter(Boolean)
|
||
const bw = 148, bh = lines.length * 14 + 14
|
||
return (
|
||
<g transform={`translate(${ox},${oy})`} style={{ pointerEvents: "none" }}>
|
||
<rect x="0" y="0" width={bw} height={bh} rx="6"
|
||
fill="#0a1628" 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 ? "#f1f5f9" : "#94a3b8"}
|
||
fontFamily="ui-monospace,monospace">
|
||
{l}
|
||
</text>
|
||
))}
|
||
</g>
|
||
)
|
||
}
|
||
|
||
function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onMouseDown }: {
|
||
n: Server & { x: number; y: number }
|
||
isSel: boolean; isVis: boolean; isDragged: boolean
|
||
/** Не дублировать «N мс» из каталога, если на карте уже показаны метрики GRE (оранжевые числа в круге). */
|
||
hideCatalogLatency?: boolean
|
||
onClick: () => void
|
||
onDblClick: () => void
|
||
onHoverChange: (hov: boolean) => void
|
||
onContextMenu: (e: React.MouseEvent) => void
|
||
onMouseDown: (e: React.MouseEvent) => void
|
||
}) {
|
||
const ss = STATUS_STYLE[n.status]
|
||
const ts = TYPE_STYLE[n.type]
|
||
|
||
return (
|
||
<g transform={`translate(${n.x},${n.y})`}
|
||
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
|
||
opacity={isVis ? 1 : 0.07}
|
||
onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }}
|
||
onClick={e => { e.stopPropagation(); onClick() }}
|
||
onDoubleClick={e => { e.stopPropagation(); onDblClick() }}
|
||
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); onContextMenu(e) }}
|
||
onMouseEnter={() => onHoverChange(true)}
|
||
onMouseLeave={() => onHoverChange(false)}
|
||
>
|
||
{n.status === "online" && isVis && (
|
||
<circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
|
||
<animate attributeName="r" values={`${ts.r+10};${ts.r+18};${ts.r+10}`} dur="3s" repeatCount="indefinite" />
|
||
<animate attributeName="opacity" values="0.8;0.15;0.8" dur="3s" repeatCount="indefinite" />
|
||
</circle>
|
||
)}
|
||
{n.status === "degraded" && isVis && (
|
||
<circle r={ts.r + 12} fill={ss.glow} />
|
||
)}
|
||
{isSel && (
|
||
<circle r={ts.r + 10} fill="none" stroke="rgba(255,255,255,0.45)" strokeWidth="1.5" strokeDasharray="4 3" />
|
||
)}
|
||
<circle r={ts.r} fill={ss.fill} stroke={ss.stroke} strokeWidth={isSel ? 2.5 : 1.8} />
|
||
<g transform={`translate(${ts.r - 10},-${ts.r - 10})`}>
|
||
<circle r="10" fill={ts.fill} />
|
||
<text textAnchor="middle" y="4" fontSize="6.5" fontWeight="bold" fill="#fff"
|
||
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: "#f1f5f9",
|
||
lineHeight: 1.25,
|
||
}}
|
||
>
|
||
{n.site}
|
||
</span>
|
||
</div>
|
||
</foreignObject>
|
||
) : (
|
||
<text textAnchor="middle" y="-4" fontSize={ts.r > 32 ? "13" : "11"} fontWeight="700"
|
||
fill="#f1f5f9" fontFamily="ui-monospace,monospace">{n.site}</text>
|
||
)}
|
||
{!hideCatalogLatency && (
|
||
<text textAnchor="middle" y="12" fontSize="8.5" fill={latencyColor(n.latency)} fontFamily="ui-monospace,monospace">
|
||
{n.latency == null ? "офлайн" : `${n.latency} мс`}
|
||
</text>
|
||
)}
|
||
{n.type === "home-router" && (
|
||
<text textAnchor="middle" y={hideCatalogLatency ? 15 : ts.r + 18} fontSize="8.5" fill="#94a3b8"
|
||
fontFamily="ui-monospace,monospace">{shortName(n.name)}</text>
|
||
)}
|
||
</g>
|
||
)
|
||
}
|
||
|
||
function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: {
|
||
x: number; y: number
|
||
wan: { name: string; isp: string; maxDl: number; maxUl: number }
|
||
color: string; active: boolean; isSel: boolean; isDragged: boolean
|
||
onSelect: () => void
|
||
onMouseDown: (e: React.MouseEvent) => void
|
||
}) {
|
||
const r = 18
|
||
return (
|
||
<g transform={`translate(${x},${y})`}
|
||
style={{ cursor: isDragged ? "grabbing" : "grab" }}
|
||
onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }}
|
||
onClick={e => { e.stopPropagation(); onSelect() }}>
|
||
{active && (
|
||
<circle r={r + 6} fill={color} opacity="0.12">
|
||
<animate attributeName="opacity" values="0.12;0.04;0.12" dur="2.5s" repeatCount="indefinite" />
|
||
</circle>
|
||
)}
|
||
{isSel && (
|
||
<circle r={r + 8} fill="none" stroke={color} strokeWidth="1.2" strokeDasharray="3 2" opacity="0.6" />
|
||
)}
|
||
<circle r={r} fill={`${color}1a`} 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="#64748b" 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="#475569" 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 className="size-3" />,
|
||
label: "Открыть терминал",
|
||
href: `/terminal?host=${menu.server.host}`,
|
||
},
|
||
{
|
||
icon: <ShieldIcon className="size-3" />,
|
||
label: "Firewall",
|
||
href: `/firewall?server=${menu.server.id}`,
|
||
},
|
||
{
|
||
icon: <CableIcon className="size-3" />,
|
||
label: "GRE-туннели",
|
||
href: `/gre?server=${menu.server.id}`,
|
||
},
|
||
{
|
||
icon: <ActivityIcon className="size-3" />,
|
||
label: "Трафик",
|
||
href: `/traffic?server=${menu.server.id}`,
|
||
},
|
||
]
|
||
|
||
return (
|
||
<>
|
||
{/* backdrop */}
|
||
<div className="fixed inset-0 z-40" onClick={onClose} />
|
||
<div
|
||
className="fixed z-50 min-w-[190px] rounded-lg border border-border bg-popover shadow-xl py-1 overflow-hidden"
|
||
style={{ left: menu.x, top: menu.y }}>
|
||
{/* header */}
|
||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 mb-1">
|
||
<span className={cn(
|
||
"size-2 rounded-full",
|
||
menu.server.status === "online" ? "bg-emerald-400" :
|
||
menu.server.status === "degraded" ? "bg-amber-400" : "bg-red-400",
|
||
)} />
|
||
<span className="text-xs font-mono font-semibold truncate">{menu.server.name}</span>
|
||
<span className="ml-auto text-[10px] font-mono text-muted-foreground">{menu.server.host}</span>
|
||
</div>
|
||
|
||
{items.map(item => (
|
||
<a
|
||
key={item.label}
|
||
href={item.href}
|
||
onClick={onClose}
|
||
className="flex items-center gap-2.5 px-3 py-2 text-xs text-foreground hover:bg-muted transition-colors cursor-pointer">
|
||
<span className="text-muted-foreground">{item.icon}</span>
|
||
{item.label}
|
||
<ExternalLinkIcon className="size-2.5 ml-auto text-muted-foreground/50" />
|
||
</a>
|
||
))}
|
||
|
||
<div className="border-t border-border/60 mt-1 pt-1">
|
||
<button
|
||
onClick={() => { navigator.clipboard.writeText(menu.server.host); onClose() }}
|
||
className="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-foreground hover:bg-muted transition-colors">
|
||
<span className="text-muted-foreground"><CopyIcon className="size-3" /></span>
|
||
Скопировать IP
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ─── Minimap ──────────────────────────────────────────────────────────────────
|
||
|
||
const MM_W = 172, MM_H = 94
|
||
|
||
function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, onClose, onPan }: {
|
||
pan: { x: number; y: number }; zoom: number
|
||
nodes: (Server & { x: number; y: number })[]
|
||
greEdges: GreMapEdge[]
|
||
satPos: Record<string, { x: number; y: number }[]>
|
||
wanJhEdges: WanJhEdge[]
|
||
homeRouters: Server[]
|
||
onClose: () => void
|
||
onPan: (x: number, y: number) => void
|
||
}) {
|
||
const vpW = Math.min(W, W / zoom)
|
||
const vpH = Math.min(H, H / zoom)
|
||
|
||
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
|
||
e.stopPropagation()
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
const svgX = (e.clientX - rect.left) / rect.width * W
|
||
const svgY = (e.clientY - rect.top) / rect.height * H
|
||
onPan(svgX - vpW / 2, svgY - vpH / 2)
|
||
}
|
||
|
||
return (
|
||
<div className="absolute bottom-16 right-4 z-20 rounded-lg overflow-hidden
|
||
border border-white/10 bg-[#060d1a]/90 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="#060d1a" />
|
||
{/* 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={WAN_COLORS[i] + "33"} stroke={WAN_COLORS[i]} strokeWidth="3" opacity="0.7" />
|
||
))
|
||
)}
|
||
{/* viewport rect */}
|
||
<rect x={pan.x} y={pan.y} width={W / zoom} height={H / zoom}
|
||
fill="rgba(255,255,255,0.04)" stroke="rgba(255,255,255,0.6)" strokeWidth="6" rx="6" />
|
||
</svg>
|
||
<button
|
||
onClick={e => { e.stopPropagation(); onClose() }}
|
||
className="absolute top-1 right-1 text-white/30 hover:text-white/80 transition-colors leading-none">
|
||
<XIcon className="size-2.5" />
|
||
</button>
|
||
<span className="absolute bottom-1 left-2 text-[7px] font-mono text-white/20 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-white/10 bg-black/75 backdrop-blur-md shadow-xl overflow-hidden">
|
||
<button onClick={onZoomOut}
|
||
className="px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
|
||
title="Уменьшить (-)">
|
||
<ZoomOutIcon className="size-3.5" />
|
||
</button>
|
||
<span className="px-3 py-2 text-xs font-mono text-white/70 min-w-[52px] text-center border-x border-white/10 select-none">
|
||
{Math.round(zoom * 100)}%
|
||
</span>
|
||
<button onClick={onZoomIn}
|
||
className="px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
|
||
title="Увеличить (+)">
|
||
<ZoomInIcon className="size-3.5" />
|
||
</button>
|
||
<div className="w-px h-5 bg-white/10" />
|
||
<button onClick={onFit}
|
||
className="px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
|
||
title="Вписать (F / 0)">
|
||
<Maximize2Icon className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
export default function NetworkMapPage() {
|
||
const { mode, backendUrl } = useDataSource()
|
||
const useLiveData = mode === "live"
|
||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||
|
||
const [mapServers, setMapServers] = useState<Server[]>([])
|
||
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
|
||
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
|
||
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
||
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
||
const [dataError, setDataError] = useState<string | null>(null)
|
||
const [dataLoading, setDataLoading] = useState(false)
|
||
|
||
const greResolvedMap = useMemo(
|
||
() => new Map(Object.entries(greResolvedIpv4ByHost)) as ReadonlyMap<string, string>,
|
||
[greResolvedIpv4ByHost],
|
||
)
|
||
|
||
const loadLive = useCallback(async () => {
|
||
if (!useLiveData) return
|
||
setDataLoading(true)
|
||
setDataError(null)
|
||
try {
|
||
const [rows, fr, spRes] = await Promise.all([
|
||
apiFetch<BackendServerRow[]>("/api/servers"),
|
||
apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels").catch(() => ({ tunnels: [] })),
|
||
apiFetch<{
|
||
probes?: Array<{
|
||
id: string
|
||
srcServerId: string
|
||
dstServerId: string
|
||
enabled?: boolean
|
||
srcInterface?: string
|
||
dstInterface?: string
|
||
lastTxAvgMbps?: number | null
|
||
lastRxAvgMbps?: number | null
|
||
lastPingRttMs?: number | null
|
||
}>
|
||
}>("/api/uptime/speed-probes").catch(() => ({ probes: [] })),
|
||
])
|
||
setMapServers(rows.map(mapBackendRowToServer))
|
||
const mappedTunnels = (fr.tunnels ?? []).map(apiGreToGreTunnel)
|
||
setMapGreTunnels(mappedTunnels)
|
||
setSpeedProbes(
|
||
(spRes.probes ?? []).map((p) => ({
|
||
id: p.id,
|
||
srcServerId: String(p.srcServerId),
|
||
dstServerId: String(p.dstServerId),
|
||
enabled: p.enabled !== false,
|
||
srcInterface: p.srcInterface || undefined,
|
||
dstInterface: p.dstInterface || undefined,
|
||
lastTxAvgMbps: p.lastTxAvgMbps,
|
||
lastRxAvgMbps: p.lastRxAvgMbps,
|
||
lastPingRttMs: p.lastPingRttMs,
|
||
})),
|
||
)
|
||
const hostSet = new Set<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]
|
||
if (hostnames.length > 0) {
|
||
try {
|
||
const res = await apiFetch<{ results: Record<string, string | null> }>(
|
||
"/api/network/resolve-hosts",
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ hosts: hostnames }),
|
||
},
|
||
)
|
||
setGreResolvedIpv4ByHost(Object.fromEntries(greResolvedMapFromApi(res.results)))
|
||
} catch {
|
||
setGreResolvedIpv4ByHost({})
|
||
}
|
||
} else {
|
||
setGreResolvedIpv4ByHost({})
|
||
}
|
||
} catch (e) {
|
||
setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||
} finally {
|
||
setDataLoading(false)
|
||
}
|
||
}, [apiFetch, useLiveData])
|
||
|
||
useEffect(() => {
|
||
if (!useLiveData) {
|
||
queueMicrotask(() => {
|
||
setMapServers(mockServers)
|
||
setMapGreTunnels(mockGreTunnels)
|
||
setSpeedProbes([])
|
||
setGreResolvedIpv4ByHost({})
|
||
setDataError(null)
|
||
})
|
||
return
|
||
}
|
||
queueMicrotask(() => {
|
||
void loadLive()
|
||
})
|
||
}, [useLiveData, loadLive])
|
||
|
||
const autoLayout = useMemo(
|
||
() => computeNetworkMapLayout(mapServers, mapGreTunnels, greResolvedMap),
|
||
[mapServers, mapGreTunnels, greResolvedMap],
|
||
)
|
||
const wanJhEdges = useMemo(() => buildWanJhEdges(mapServers), [mapServers])
|
||
const srvResMap = useMemo(() => buildServerResourceMap(mapServers), [mapServers])
|
||
const homeRouters = useMemo(
|
||
() => mapServers.filter((s) => s.type === "home-router"),
|
||
[mapServers],
|
||
)
|
||
|
||
// ── View state ──────────────────────────────────────────────────────────────
|
||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||
const [zoom, setZoom] = useState(1)
|
||
const [isDragging, setIsDragging] = useState(false)
|
||
|
||
// ── Interaction ─────────────────────────────────────────────────────────────
|
||
const [selected, setSelected] = useState<Server | null>(null)
|
||
const [selWanIdx, setSelWanIdx] = useState<number | null>(null)
|
||
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 }[]>>({})
|
||
|
||
/** После обновления алгоритма раскладки (см. NETWORK_MAP_LAYOUT_REVISION) сбрасываем drag, иначе старые координаты «перебивают» computeNetworkMapLayout. */
|
||
useEffect(() => {
|
||
if (typeof window === "undefined") return
|
||
try {
|
||
const k = "mm-network-map-layout-rev"
|
||
if (sessionStorage.getItem(k) !== String(NETWORK_MAP_LAYOUT_REVISION)) {
|
||
// Важно: без явного сброса старые drag-координаты остаются в state
|
||
// и перекрывают новый авто-лейаут на live-данных.
|
||
setNodePositions({})
|
||
setSatPositions({})
|
||
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
|
||
}
|
||
} catch {
|
||
/* private mode / disabled storage */
|
||
}
|
||
}, [])
|
||
|
||
// ── Context menu ────────────────────────────────────────────────────────────
|
||
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
|
||
|
||
const [selectedGreEdge, setSelectedGreEdge] = useState<GreMapEdge | null>(null)
|
||
|
||
// ── Toggles ─────────────────────────────────────────────────────────────────
|
||
const [filter, setFilter] = useState<FilterKey>("all")
|
||
const [search, setSearch] = useState("")
|
||
const [showPingBadges, setShowPingBadges] = useState(true)
|
||
const [showAnimDots, setShowAnimDots] = useState(true)
|
||
const [showMinimap, setShowMinimap] = useState(true)
|
||
const [showHints, setShowHints] = useState(false)
|
||
const [showLayers, setShowLayers] = useState(false)
|
||
|
||
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],
|
||
)
|
||
|
||
/** 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))
|
||
if (!merged.hasSpeedMonitor) continue
|
||
s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`)
|
||
}
|
||
return s
|
||
}, [greEdges, speedProbeByTunnelId, greResolvedMap])
|
||
|
||
/** WAN→JH: скрываем саму линию, если тот же отрезок уже отрисован GRE (одна связь на карте). */
|
||
const suppressWanJhLineDuplicate = useMemo(() => {
|
||
const s = new Set<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))
|
||
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])
|
||
|
||
/** Несколько 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 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]))
|
||
|
||
// ── Refs ─────────────────────────────────────────────────────────────────────
|
||
const svgRef = useRef<SVGSVGElement>(null)
|
||
|
||
// Canvas pan drag
|
||
const dragRef = useRef<{
|
||
startX: number; startY: number
|
||
panX: number; panY: number
|
||
moved: boolean
|
||
} | null>(null)
|
||
|
||
// Node / WAN-sat drag
|
||
type NodeDrag =
|
||
| { kind: "server"; nodeId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean }
|
||
| { kind: "wan-sat"; homeId: string; wanIdx: number; startX: number; startY: number; origX: number; origY: number; moved: boolean }
|
||
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 zoomRef = useRef(zoom)
|
||
const panRef = useRef(pan)
|
||
zoomRef.current = zoom
|
||
panRef.current = pan
|
||
|
||
// ── SVG export ───────────────────────────────────────────────────────────
|
||
function exportSvg() {
|
||
const svg = svgRef.current
|
||
if (!svg) return
|
||
// Clone with full viewBox (reset pan/zoom)
|
||
const clone = svg.cloneNode(true) as SVGSVGElement
|
||
clone.setAttribute("viewBox", `0 0 ${W} ${H}`)
|
||
clone.setAttribute("width", String(W))
|
||
clone.setAttribute("height", String(H))
|
||
const blob = new Blob(
|
||
[`<?xml version="1.0" encoding="UTF-8"?>\n`, clone.outerHTML],
|
||
{ type: "image/svg+xml;charset=utf-8" },
|
||
)
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement("a")
|
||
a.href = url
|
||
a.download = `network-map-${new Date().toISOString().slice(0, 10)}.svg`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
// ── Non-passive wheel zoom ────────────────────────────────────────────────
|
||
useEffect(() => {
|
||
const svg = svgRef.current
|
||
if (!svg) return
|
||
const handler = (e: WheelEvent) => {
|
||
e.preventDefault()
|
||
const z = zoomRef.current
|
||
const p = panRef.current
|
||
const rect = svg.getBoundingClientRect()
|
||
const svgX = p.x + (e.clientX - rect.left) / rect.width * (W / z)
|
||
const svgY = p.y + (e.clientY - rect.top) / rect.height * (H / z)
|
||
// trackpad pinch sends ctrlKey, use smaller step
|
||
const step = e.ctrlKey ? 1.06 : 1.14
|
||
const factor = e.deltaY < 0 ? step : 1 / step
|
||
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z * factor))
|
||
setPan({
|
||
x: svgX - (e.clientX - rect.left) / rect.width * (W / newZoom),
|
||
y: svgY - (e.clientY - rect.top) / rect.height * (H / newZoom),
|
||
})
|
||
setZoom(newZoom)
|
||
}
|
||
svg.addEventListener("wheel", handler, { passive: false })
|
||
return () => svg.removeEventListener("wheel", handler)
|
||
}, [])
|
||
|
||
// ── Keyboard shortcuts ────────────────────────────────────────────────────
|
||
useEffect(() => {
|
||
function onKey(e: KeyboardEvent) {
|
||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||
if (e.key === "Escape") { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null) }
|
||
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
|
||
if (e.key === "-") applyZoomCenter(1 / 1.25)
|
||
if (e.key === "0" || e.key.toLowerCase() === "f") fitView()
|
||
if (e.key.toLowerCase() === "m") setShowMinimap(v => !v)
|
||
if (e.key.toLowerCase() === "p") setShowPingBadges(v => !v)
|
||
}
|
||
window.addEventListener("keydown", onKey)
|
||
return () => window.removeEventListener("keydown", onKey)
|
||
}, [zoom, pan]) // eslint-disable-line react-hooks/exhaustive-deps -- applyZoomCenter/fitView ниже; handler обновляется через [zoom, pan]
|
||
|
||
// ── Zoom helpers ──────────────────────────────────────────────────────────
|
||
function applyZoomCenter(factor: number) {
|
||
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
|
||
setPan(p => ({
|
||
x: p.x + (W / zoom - W / newZoom) / 2,
|
||
y: p.y + (H / zoom - H / newZoom) / 2,
|
||
}))
|
||
setZoom(newZoom)
|
||
}
|
||
|
||
function fitView() {
|
||
setPan({ x: 0, y: 0 })
|
||
setZoom(1)
|
||
}
|
||
|
||
function focusNode(x: number, y: number) {
|
||
const newZoom = Math.min(ZOOM_MAX, Math.max(zoom * 1.8, 2))
|
||
setPan({ x: x - W / (2 * newZoom), y: y - H / (2 * newZoom) })
|
||
setZoom(newZoom)
|
||
}
|
||
|
||
// ── Drag ─────────────────────────────────────────────────────────────────
|
||
function onSvgMouseDown(e: React.MouseEvent<SVGSVGElement>) {
|
||
// Only pan on background clicks (node mousedown stops propagation)
|
||
dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y, moved: false }
|
||
setIsDragging(true)
|
||
}
|
||
|
||
function onSvgMouseMove(e: React.MouseEvent<SVGSVGElement>) {
|
||
const nd = nodeDragRef.current
|
||
if (nd) {
|
||
const dx = e.clientX - nd.startX
|
||
const dy = e.clientY - nd.startY
|
||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) nd.moved = true
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
const svgDx = dx * (W / zoom) / rect.width
|
||
const svgDy = dy * (H / zoom) / rect.height
|
||
const nx = nd.origX + svgDx
|
||
const ny = nd.origY + svgDy
|
||
|
||
if (nd.kind === "server") {
|
||
setNodePositions(prev => ({ ...prev, [nd.nodeId]: { x: nx, y: ny } }))
|
||
} else {
|
||
setSatPositions(prev => {
|
||
const arr = [...(prev[nd.homeId] ?? (autoLayout.wanSatPos[nd.homeId] ?? []))]
|
||
arr[nd.wanIdx] = { x: nx, y: ny }
|
||
return { ...prev, [nd.homeId]: arr }
|
||
})
|
||
}
|
||
return
|
||
}
|
||
|
||
if (!dragRef.current) return
|
||
const dx = e.clientX - dragRef.current.startX
|
||
const dy = e.clientY - dragRef.current.startY
|
||
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragRef.current.moved = true
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
setPan({
|
||
x: dragRef.current.panX - dx * (W / zoom / rect.width),
|
||
y: dragRef.current.panY - dy * (H / zoom / rect.height),
|
||
})
|
||
}
|
||
|
||
function finishNodeDrag() {
|
||
if (!nodeDragRef.current) return false
|
||
suppressClickRef.current = nodeDragRef.current.moved
|
||
nodeDragRef.current = null
|
||
setDraggedNodeId(null)
|
||
setDraggedSatKey(null)
|
||
return true
|
||
}
|
||
|
||
function onSvgMouseUp() {
|
||
if (finishNodeDrag()) return
|
||
const moved = dragRef.current?.moved ?? false
|
||
dragRef.current = null
|
||
setIsDragging(false)
|
||
if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null) }
|
||
}
|
||
|
||
// ── Node drag start ──────────────────────────────────────────────────────
|
||
function onNodeMouseDown(e: React.MouseEvent, n: Server & { x: number; y: number }) {
|
||
nodeDragRef.current = { kind: "server", nodeId: n.id, startX: e.clientX, startY: e.clientY, origX: n.x, origY: n.y, moved: false }
|
||
setDraggedNodeId(n.id)
|
||
}
|
||
function onSatMouseDown(e: React.MouseEvent, homeId: string, wanIdx: number, x: number, y: number) {
|
||
nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
|
||
setDraggedSatKey(`${homeId}-${wanIdx}`)
|
||
}
|
||
|
||
// ── Visibility / search ──────────────────────────────────────────────────
|
||
const sq = search.toLowerCase().trim()
|
||
const matchesSearch = (n: typeof nodes[0]) =>
|
||
!sq ||
|
||
n.name.toLowerCase().includes(sq) ||
|
||
n.site.toLowerCase().includes(sq) ||
|
||
n.model?.toLowerCase().includes(sq) ||
|
||
n.os?.toLowerCase().includes(sq)
|
||
|
||
const isVisible = (n: typeof nodes[0]) => {
|
||
if (!matchesSearch(n)) return false
|
||
if (filter === "all") return true
|
||
if (filter === "online" || filter === "degraded" || filter === "offline") return n.status === filter
|
||
return n.type === filter
|
||
}
|
||
|
||
// ── Side panel ────────────────────────────────────────────────────────────
|
||
function selectServer(s: Server) {
|
||
setSelectedGreEdge(null)
|
||
setSelected(prev => prev?.id === s.id ? null : s)
|
||
setSelWanIdx(null)
|
||
setHoveredId(null)
|
||
}
|
||
function selectWan(s: Server, wanIdx: number) {
|
||
setSelectedGreEdge(null)
|
||
setSelected(s)
|
||
setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx)
|
||
}
|
||
|
||
function openWanJhSpeedDetail(ev: React.MouseEvent<SVGElement>, edge: WanJhEdge) {
|
||
ev.stopPropagation()
|
||
const home = mapServers.find((s) => s.id === edge.homeId)
|
||
if (!home) return
|
||
setSelectedGreEdge(null)
|
||
setSelected(home)
|
||
setSelWanIdx(edge.wanIdx)
|
||
setHoveredId(null)
|
||
}
|
||
|
||
const connectedTunnels = selected
|
||
? mapGreTunnels.filter((t) => {
|
||
if (t.serverId === selected.id) return true
|
||
const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
|
||
if (peer?.id === selected.id) return true
|
||
const selectedHost = normalizeGreEndpointAddr(selected.host)
|
||
const selectedWanIps = (selected.wanUplinks ?? [])
|
||
.map((w) => normalizeGreEndpointAddr(w.ip))
|
||
.filter(Boolean)
|
||
const remote = normalizeGreEndpointAddr(t.remoteAddress)
|
||
const local = normalizeGreEndpointAddr(t.localAddress)
|
||
return (
|
||
(selectedHost.length > 0 && (remote === selectedHost || local === selectedHost)) ||
|
||
selectedWanIps.includes(remote) ||
|
||
selectedWanIps.includes(local)
|
||
)
|
||
})
|
||
: []
|
||
const grePrimaryByGroupKey = useMemo(() => {
|
||
if (!selected) return new Map<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: "jump-host", label: "JumpHost" },
|
||
{ value: "exit-node", label: "Exit Node" },
|
||
{ value: "home-router", label: "Home Router"},
|
||
]
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Обзор" }, { label: "Карта сети" }]}
|
||
actions={
|
||
<>
|
||
{useLiveData && (
|
||
<Button variant="outline" size="sm" onClick={() => void loadLive()} disabled={dataLoading}>
|
||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||
Обновить
|
||
</Button>
|
||
)}
|
||
<Button variant="outline" size="sm" onClick={exportSvg}>
|
||
<DownloadIcon className="size-4" />Экспорт SVG
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
{/* ── Toolbar ── */}
|
||
<div className="flex items-center gap-2 px-3 py-2 border-b bg-background/60 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">
|
||
{filterBtns.map(b => (
|
||
<button key={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 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)}
|
||
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 onClick={() => setSearch("")}
|
||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||
<XIcon className="size-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Layers dropdown */}
|
||
<div className="relative">
|
||
<button
|
||
onClick={() => setShowLayers(v => !v)}
|
||
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 className="size-3" />Слои
|
||
</button>
|
||
{showLayers && (
|
||
<div className="absolute top-9 left-0 z-30 rounded-lg border border-border bg-popover shadow-xl p-2 flex flex-col gap-0.5 min-w-[170px]"
|
||
onMouseLeave={() => setShowLayers(false)}>
|
||
{([
|
||
{ key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" },
|
||
{ 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 key={item.key}
|
||
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>
|
||
))}
|
||
{(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0) && (
|
||
<div className="border-t border-border/50 mt-1 pt-1">
|
||
<button
|
||
onClick={() => { setNodePositions({}); setSatPositions({}) }}
|
||
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>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Stats */}
|
||
<div className="ml-auto flex items-center gap-4 text-xs text-muted-foreground flex-wrap justify-end">
|
||
<span className="text-[10px] border border-border rounded px-1.5 py-0.5">
|
||
{useLiveData ? "Живые данные · API" : "Демо · mock"}
|
||
</span>
|
||
{dataError && (
|
||
<span className="text-[10px] text-destructive max-w-[220px] truncate" title={dataError}>
|
||
{dataError}
|
||
</span>
|
||
)}
|
||
<span>
|
||
<span className="font-semibold text-emerald-400">{mapServers.filter(s => s.status === "online").length}</span> онлайн
|
||
</span>
|
||
<span>
|
||
<span className="font-semibold text-amber-400">{mapServers.filter(s => s.status === "degraded").length}</span> с проблемами
|
||
</span>
|
||
<span>
|
||
<span className="font-semibold text-red-400">{mapServers.filter(s => s.status === "offline").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>
|
||
</div>
|
||
|
||
{/* ── Main area ── */}
|
||
<div className="flex flex-1 overflow-hidden">
|
||
|
||
{/* ── Canvas ── */}
|
||
<div className="flex-1 relative overflow-hidden bg-[#060d1a] select-none">
|
||
<svg
|
||
ref={svgRef}
|
||
viewBox={`${pan.x} ${pan.y} ${W / zoom} ${H / zoom}`}
|
||
style={{ width: "100%", height: "100%", display: "block", cursor: isDragging ? "grabbing" : "grab", userSelect: "none", WebkitUserSelect: "none" }}
|
||
onMouseDown={e => { e.preventDefault(); onSvgMouseDown(e) }}
|
||
onMouseMove={onSvgMouseMove}
|
||
onMouseUp={onSvgMouseUp}
|
||
onMouseLeave={() => {
|
||
finishNodeDrag()
|
||
dragRef.current = null; setIsDragging(false); setHoveredId(null)
|
||
}}
|
||
>
|
||
<defs>
|
||
<pattern id="map-dots" width="22" height="22" patternUnits="userSpaceOnUse">
|
||
<circle cx="1" cy="1" r="0.8" fill="rgba(148,163,184,0.08)" />
|
||
</pattern>
|
||
{WAN_COLORS.map((_, i) => (
|
||
<filter key={i} id={`glow-wan-${i}`} x="-80%" y="-80%" width="260%" height="260%">
|
||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
|
||
</filter>
|
||
))}
|
||
</defs>
|
||
|
||
{/* Background grid */}
|
||
<rect width={W} height={H} fill="url(#map-dots)" />
|
||
|
||
{/* ── GRE edges ── */}
|
||
{greEdges.map((e, i) => {
|
||
const edgeId = greEdgeKey(e)
|
||
const ts = TUNNEL_STYLE[e.tunnel.status]
|
||
const fromN = nodeById[e.fromServer.id]
|
||
const toN = nodeById[e.toServer.id]
|
||
const dimmed =
|
||
filter !== "all" &&
|
||
fromN && toN &&
|
||
!isVisible(fromN) &&
|
||
!isVisible(toN)
|
||
const baseProbe = greTunnelProbe(e.tunnel)
|
||
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,
|
||
)
|
||
function openGreDetail(ev: React.MouseEvent<SVGElement>) {
|
||
ev.stopPropagation()
|
||
setSelectedGreEdge(e)
|
||
setSelected(null)
|
||
setSelWanIdx(null)
|
||
}
|
||
return (
|
||
<g key={edgeId} opacity={dimmed ? 0.05 : 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="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="#00000000"
|
||
strokeWidth={14}
|
||
strokeLinecap="round"
|
||
style={{ cursor: "pointer" }}
|
||
onPointerDown={(ev) => { ev.stopPropagation() }}
|
||
onClick={openGreDetail}
|
||
>
|
||
<title>GRE: подробнее</title>
|
||
</line>
|
||
{showAnimDots && 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}
|
||
rttFromMonitor={rttFromMonitor}
|
||
throughputFromMonitor={throughputFromMonitor}
|
||
outerSummary={greOuterSummaryLine(e.tunnel, fromN, toN, greResolvedMap)}
|
||
/>
|
||
)}
|
||
</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] ?? "#888"
|
||
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] ?? "#888"
|
||
const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online"
|
||
const { mx, my } = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.62, -17)
|
||
const isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx)
|
||
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 ? 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 && 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: 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)}
|
||
rttFromMonitor={sp?.lastPingRttMs != null}
|
||
throughputFromMonitor={
|
||
sp?.lastTxAvgMbps != null || sp?.lastRxAvgMbps != null
|
||
}
|
||
/>
|
||
)
|
||
}
|
||
return <PingBadge mx={mx} my={my} ping={edge.pingMs} color={pingColor(edge.pingMs)} />
|
||
})()}
|
||
</g>
|
||
)
|
||
})}
|
||
|
||
{/* ── Server nodes ── */}
|
||
{nodes.map(n => (
|
||
<ServerNode
|
||
key={n.id} n={n}
|
||
isSel={selected?.id === n.id}
|
||
isVis={isVisible(n)}
|
||
isDragged={draggedNodeId === n.id}
|
||
hideCatalogLatency={hideCatalogLatencyByNodeId.has(n.id)}
|
||
onClick={() => {
|
||
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
||
selectServer(n)
|
||
}}
|
||
onDblClick={() => { selectServer(n); focusNode(n.x, n.y) }}
|
||
onHoverChange={h => setHoveredId(h ? n.id : null)}
|
||
onContextMenu={e => setCtxMenu({ x: e.clientX, y: e.clientY, server: n })}
|
||
onMouseDown={e => onNodeMouseDown(e, n)}
|
||
/>
|
||
))}
|
||
|
||
{/* ── WAN satellite nodes ── */}
|
||
{homeRouters.flatMap(router => {
|
||
const vis = filter === "all" || filter === "home-router" || filter === "online"
|
||
return (router.wanUplinks ?? []).map((wan, wIdx) => {
|
||
const sat = effectiveSatPos[router.id]?.[wIdx]
|
||
if (!sat) return null
|
||
const color = WAN_COLORS[wIdx] ?? "#888"
|
||
const isActive = wanJhEdges.filter(e => e.homeId === router.id && e.wanIdx === wIdx).some(e => e.active)
|
||
const isSatSel = selected?.id === router.id && selWanIdx === wIdx
|
||
const satKey = `${router.id}-${wIdx}`
|
||
return (
|
||
<g key={`${router.id}-wan-${wIdx}`} opacity={vis ? 1 : 0.06} style={{ transition: "opacity 0.3s" }}>
|
||
<WanSatNode
|
||
x={sat.x} y={sat.y} wan={wan} color={color}
|
||
active={isActive} isSel={isSatSel}
|
||
isDragged={draggedSatKey === satKey}
|
||
onSelect={() => {
|
||
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
||
selectWan(router, wIdx)
|
||
}}
|
||
onMouseDown={e => onSatMouseDown(e, router.id, wIdx, sat.x, sat.y)}
|
||
/>
|
||
</g>
|
||
)
|
||
}).filter(Boolean)
|
||
})}
|
||
|
||
{/* ── Hover tooltip ── */}
|
||
{hoveredNode && !isDragging && (
|
||
<SvgTooltip n={hoveredNode} />
|
||
)}
|
||
|
||
{/* ── Legend (viewport-fixed) ── */}
|
||
<g transform={`translate(${pan.x + 14}, ${pan.y + 14})`}>
|
||
<rect width="140" height="224" rx="8"
|
||
fill="rgba(6,13,26,0.88)" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
|
||
<text x="10" y="22" fontSize="8" fontWeight="700" fill="#64748b"
|
||
fontFamily="system-ui" letterSpacing="0.08em">ЛЕГЕНДА</text>
|
||
|
||
{(["online","degraded","offline"] as const).map((s, i) => (
|
||
<g key={s} transform={`translate(10, ${34 + i * 18})`}>
|
||
<circle cx="5" cy="0" r="4.5" fill={STATUS_STYLE[s].stroke} opacity="0.85" />
|
||
<text x="16" y="4" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">
|
||
{s === "online" ? "Онлайн" : s === "degraded" ? "Внимание" : "Оффлайн"}
|
||
</text>
|
||
</g>
|
||
))}
|
||
|
||
<line x1="10" y1="100" x2="130" y2="100" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
|
||
|
||
{[
|
||
{ fill: "#7c3aed", label: "JH", text: "JumpHost" },
|
||
{ fill: "#0369a1", label: "EN", text: "Exit Node" },
|
||
{ fill: "#16a34a", label: "HR", text: "Home Router" },
|
||
].map((t, i) => (
|
||
<g key={t.label} transform={`translate(10, ${110 + i * 18})`}>
|
||
<rect width="14" height="14" rx="3" fill={t.fill} />
|
||
<text x="5.5" y="11" fontSize="6" fontWeight="bold" fill="#fff"
|
||
textAnchor="middle" fontFamily="system-ui">{t.label}</text>
|
||
<text x="22" y="11" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">{t.text}</text>
|
||
</g>
|
||
))}
|
||
|
||
<line x1="10" y1="166" x2="130" y2="166" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
|
||
|
||
<text x="10" y="180" fontSize="7.5" fontWeight="700" fill="#475569"
|
||
fontFamily="system-ui" letterSpacing="0.05em">WAN АПЛИНКИ</text>
|
||
{WAN_COLORS.slice(0, 2).map((c, i) => (
|
||
<g key={i} transform={`translate(10, ${190 + i * 14})`}>
|
||
<circle cx="5" cy="4" r="4" fill={c} opacity="0.9" />
|
||
<text x="16" y="8" fontSize="8" fill="#94a3b8" fontFamily="ui-monospace,monospace">
|
||
WAN{i + 1}
|
||
</text>
|
||
<line x1="38" y1="4" x2="60" y2="4" stroke={c}
|
||
strokeWidth={i === 0 ? 2 : 1.2} strokeDasharray={i === 0 ? "none" : "4 3"} opacity="0.8" />
|
||
<text x="66" y="8" fontSize="7.5" fill="#475569" fontFamily="system-ui">
|
||
{i === 0 ? "primary" : "backup"}
|
||
</text>
|
||
</g>
|
||
))}
|
||
</g>
|
||
|
||
{/* ── Keyboard hints (viewport-fixed) ── */}
|
||
{showHints && (
|
||
<g transform={`translate(${pan.x + W / zoom - 14}, ${pan.y + H / zoom - 14})`}>
|
||
{[
|
||
["Scroll", "zoom"],
|
||
["+/-", "zoom in/out"],
|
||
["F / 0", "вписать"],
|
||
["M", "minimap"],
|
||
["P", "ping"],
|
||
["Esc", "снять выбор"],
|
||
["2× клик", "навигация"],
|
||
].reverse().map(([k, l], i) => (
|
||
<g key={k} transform={`translate(0, ${-(i * 16)})`}>
|
||
<rect x="-120" y="-12" width="120" height="14" rx="3"
|
||
fill="rgba(6,13,26,0.82)" />
|
||
<text x="-60" y="-1" textAnchor="middle" fontSize="7.5"
|
||
fill="#64748b" fontFamily="system-ui">
|
||
<tspan fontWeight="600" fill="#94a3b8">{k}</tspan> {l}
|
||
</text>
|
||
</g>
|
||
))}
|
||
</g>
|
||
)}
|
||
</svg>
|
||
|
||
{/* ── Zoom controls ── */}
|
||
<ZoomControls
|
||
zoom={zoom}
|
||
onZoomIn={() => applyZoomCenter(1.3)}
|
||
onZoomOut={() => applyZoomCenter(1 / 1.3)}
|
||
onFit={fitView}
|
||
/>
|
||
|
||
{/* ── Minimap ── */}
|
||
{showMinimap && (
|
||
<Minimap
|
||
pan={pan} zoom={zoom}
|
||
nodes={nodes}
|
||
greEdges={greEdges}
|
||
satPos={effectiveSatPos}
|
||
wanJhEdges={visibleWanJhEdges}
|
||
homeRouters={homeRouters}
|
||
onClose={() => setShowMinimap(false)}
|
||
onPan={(x, y) => setPan({ x, y })}
|
||
/>
|
||
)}
|
||
|
||
{/* ── "Show minimap" button when hidden ── */}
|
||
{!showMinimap && (
|
||
<button
|
||
onClick={() => setShowMinimap(true)}
|
||
className="absolute bottom-16 right-4 z-20 p-2 rounded-lg border border-white/10
|
||
bg-black/70 backdrop-blur-sm text-white/40 hover:text-white/80 transition-colors"
|
||
title="Показать миникарту (M)">
|
||
<MapIcon 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-white/10 bg-black/75 backdrop-blur-sm text-xs font-mono text-white/70">
|
||
{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) && (
|
||
<div className="border-l flex flex-col overflow-hidden shrink-0 bg-background" style={{ width: 300 }}>
|
||
{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 truncate">{selectedGreEdge.tunnel.name}</p>
|
||
<p className="text-xs text-muted-foreground font-mono mt-0.5 leading-snug truncate">
|
||
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"
|
||
title="Навести на карте">
|
||
<HomeIcon className="size-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedGreEdge(null)}
|
||
className="text-muted-foreground hover:text-foreground transition-colors">
|
||
<XIcon className="size-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 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)
|
||
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)" : "Модель 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)" : "Скорость (модель)"}
|
||
</span>
|
||
<span className="text-xs font-mono font-semibold text-sky-400">
|
||
{merged.dlMbps != null && merged.ulMbps != null
|
||
? `↓${merged.dlMbps} ↑${merged.ulMbps}`
|
||
: "—"}
|
||
</span>
|
||
</div>
|
||
<p className="text-[10px] text-muted-foreground pt-2 leading-snug">
|
||
{merged.hasSpeedMonitor
|
||
? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам."
|
||
: "«Модель RTT» и «скорость» — демо до появления подходящей speed-пробы в «Мониторинг → скорость»."}
|
||
</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">{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">{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">{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 className="size-3 text-emerald-400 shrink-0" /> да</>
|
||
) : (
|
||
<><LockOpenIcon className="size-3 shrink-0" /> нет</>
|
||
)}
|
||
</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 break-all 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 break-all 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">
|
||
{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>
|
||
</>
|
||
) : 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 truncate">{selected.name}</p>
|
||
<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
|
||
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"
|
||
title="Навести на карте">
|
||
<HomeIcon className="size-3.5" />
|
||
</button>
|
||
<button onClick={() => { setSelected(null); setSelWanIdx(null) }}
|
||
className="text-muted-foreground hover:text-foreground transition-colors">
|
||
<XIcon className="size-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 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], ["Сессий", 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">{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] ?? "#888"
|
||
const myEdges = wanJhEdges.filter(e => e.homeId === selected.id && e.wanIdx === wIdx)
|
||
const isActive = myEdges.some(e => e.active)
|
||
const isSel = selWanIdx === wIdx
|
||
return (
|
||
<div key={wan.id}
|
||
onClick={() => setSelWanIdx(prev => prev === wIdx ? null : wIdx)}
|
||
className={cn(
|
||
"rounded-lg border p-3 cursor-pointer transition-all",
|
||
isSel ? "bg-muted/60" : "bg-muted/20 hover:bg-muted/40",
|
||
)}
|
||
style={{ borderColor: isSel ? color : undefined }}>
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<WifiIcon 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",
|
||
)}>
|
||
{isActive ? "primary" : "backup"}
|
||
</span>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{[["ISP", wan.isp], ["Iface", wan.iface], ["IP", wan.ip],
|
||
["BW", `↓${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>
|
||
))}
|
||
</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
|
||
</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)
|
||
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 showMetrics =
|
||
merged.pingMs != null ||
|
||
(merged.dlMbps != null && merged.ulMbps != null)
|
||
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">{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 ? "primary" : "backup"}
|
||
</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 className="size-2.5" />IPsec</span>
|
||
: <span className="text-[10px] text-muted-foreground flex items-center gap-0.5"><LockOpenIcon className="size-2.5" />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>
|
||
)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Resources */}
|
||
{(() => {
|
||
const res = srvResMap[selected.id]
|
||
if (!res) return null
|
||
const cpuPct = res.cpu
|
||
const ramPct = Math.round(res.ramUsed / res.ramTotal * 100)
|
||
const hddPct = Math.round(res.hddUsed / res.hddTotal * 100)
|
||
return (
|
||
<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">
|
||
CPU, RAM и uptime — с последнего poll MikroTik. Диск в опросе не передаётся, шкала условная.
|
||
</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}%
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<MiniBar pct={cpuPct} />
|
||
<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>
|
||
<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
|
||
{res.fromPoll && (
|
||
<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>
|
||
<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
|
||
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 className="size-3" />
|
||
Навести карту на узел
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Context menu ── */}
|
||
{ctxMenu && <ContextMenu menu={ctxMenu} onClose={() => setCtxMenu(null)} />}
|
||
</div>
|
||
)
|
||
}
|