Updated the API fetching mechanism in various components to utilize the new requestJson function for improved consistency and error handling. This change affects the alerts, dashboard, data collection, filters, gre, network map, probes, recursive routes, route optimizer, servers, settings, traffic, and uptime pages.
2332 lines
104 KiB
TypeScript
2332 lines
104 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 узла) ─
|
||
|
||
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> {
|
||
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 }> = {
|
||
"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"
|
||
|
||
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, 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 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), [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)) {
|
||
// Важно: без явного сброса старые 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]! }))
|
||
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) => {
|
||
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 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, 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 && (() => {
|
||
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, 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 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 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>
|
||
<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>
|
||
)
|
||
}
|