Files
MikrotikManager/app/(main)/network-map/page.tsx
T
2026-05-03 11:16:07 +07:00

2201 lines
98 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState, useRef, useEffect, useMemo, useCallback } 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 {
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,
} from "@/lib/gre-endpoint-resolve"
import {
assignSpeedProbesToGreTunnels,
greOuterSummaryLine,
ifaceNameForGreOuterIp,
mergeGreMetricsWithSpeedProbe,
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 узла) ─
const BOARD_MAP: Record<ServerType, string> = {
"jump-host": "RB5009UG+S+IN",
"exit-node": "RB4011iGS+RM",
"home-router": "hAP ax²",
}
// ─── 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> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
...init,
headers: { ...headers, ...(init?.headers ?? {}) },
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText })) as { error?: string }
throw new Error(err.error ?? res.statusText)
}
return res.json() as Promise<T>
}
}
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 }> = {
"jump-host": { label: "JH", fill: "#7c3aed", r: 36 },
"exit-node": { label: "EN", fill: "#0369a1", r: 36 },
"home-router": { label: "HR", fill: "#16a34a", 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"
// ─── 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={e.tunnel.id} 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, i) => {
const sat = satPos[edge.homeId]?.[edge.wanIdx]
const jh = nodes.find(n => n.id === edge.jhId)
if (!sat || !jh) return null
return <line key={i} 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, backendStatus } = useDataSource()
const useLiveData = mode === "live" && backendStatus === true
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [mapServers, setMapServers] = useState<Server[]>(mockServers)
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>(mockGreTunnels)
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 hostnames = collectGreEndpointHostnames(mappedTunnels)
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), [mapServers])
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)) {
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],
)
/** WAN→JH: не показывать PingBadge, если на том же дом+WAN+JH уже есть GRE с метрик-бейджем. */
const suppressWanJhPingBadge = useMemo(() => {
const s = new Set<string>()
for (const g of greEdges) {
if (g.fromServer.type !== "home-router") continue
if (g.toServer.type !== "jump-host") 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,
fromServer: e.fromServer,
toServer: e.toServer,
})),
speedProbes,
greResolvedMap,
),
[greEdges, speedProbes, greResolvedMap],
)
/** Несколько GRE между одной парой узлов — смещаем бейджи по дуге и по нормали, без наложения. */
const greBadgeStaggerByTunnelId = useMemo(() => {
const pairOrder = new Map<string, number>()
const out = new Map<string, number>()
for (const ge of greEdges) {
const k = `${ge.fromServer.id}:${ge.toServer.id}`
const lane = pairOrder.get(k) ?? 0
out.set(ge.tunnel.id, lane)
pairOrder.set(k, lane + 1)
}
return out
}, [greEdges])
const nodes = mapServers.map((s) => ({ ...s, ...nodePosById[s.id]! }))
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)
}
const connectedTunnels = selected
? mapGreTunnels.filter((t) => {
const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
if (!peer) return false
return t.serverId === selected.id || peer.id === selected.id
})
: []
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 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(e.tunnel.id)
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
const rttFromMonitor = spGre?.lastPingRttMs != null
const throughputFromMonitor =
spGre?.lastTxAvgMbps != null || spGre?.lastRxAvgMbps != null
const badgeLane = greBadgeStaggerByTunnelId.get(e.tunnel.id) ?? 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={e.tunnel.id} 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 && (
<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 ── */}
{wanJhEdges.map((edge, i) => {
const jh = nodeById[edge.jhId]
const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx]
if (!jh || !satPos) return null
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={`wan-jh-${i}`} 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 + i * 0.3}s`} repeatCount="indefinite"
path={`M ${satPos.x} ${satPos.y} L ${jh.x} ${jh.y}`} />
</circle>
)}
{showPingBadges &&
!suppressWanJhPingBadge.has(`${edge.homeId}\t${edge.wanIdx}\t${edge.jhId}`) && (
<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={wanJhEdges}
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 localWanName = ifaceNameForGreOuterIp(edge.fromServer, t.localAddress, greResolvedMap)
const remoteWanName = ifaceNameForGreOuterIp(edge.toServer, t.remoteAddress, greResolvedMap)
const baseProbe = greTunnelProbe(t)
const spGr = speedProbeByTunnelId.get(t.id)
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, ei) => {
const jh = mapServers.find(s => s.id === e.jhId)
return (
<div key={ei} 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 baseProbe = greTunnelProbe(t)
const spGre =
fromServer && toServer ? speedProbeByTunnelId.get(t.id) : 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={t.id} 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>
<span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
{t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
</span>
</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">{BOARD_MAP[selected.type]}</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>
)
}