Files
MikrotikManager/app/(main)/network-map/page.tsx
T
2026-05-02 01:17:08 +07:00

1480 lines
68 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 } from "react"
import { PageHeader } from "@/components/page-header"
import { servers, greTunnels, type Server, type ServerType } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { StatusBadge } from "@/components/status-badge"
import {
DownloadIcon, XIcon, LockIcon, LockOpenIcon, WifiIcon,
ZoomInIcon, ZoomOutIcon, Maximize2Icon, SearchIcon,
LayersIcon, MapIcon, HomeIcon, TerminalIcon, ShieldIcon,
CableIcon, CopyIcon, ActivityIcon, ExternalLinkIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
// ─── Resource metrics (mirrors uptime/page.tsx INIT_RESOURCES formula) ───────
interface SrvRes {
cpu: number
cpuHistory: number[]
ramUsed: number; ramTotal: number
hddUsed: number; hddTotal: number
uptimeSeconds: number
temp?: number
}
const BOARD_MAP: Record<ServerType, string> = {
"jump-host": "RB5009UG+S+IN",
"exit-node": "RB4011iGS+RM",
"home-router": "hAP ax²",
}
const SERVER_RESOURCES: Record<string, SrvRes> = Object.fromEntries(
servers.map(s => {
const h = s.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0)
const cpu = 4 + (h % 68)
const ramTotal = s.type === "jump-host" ? 8192 : s.type === "exit-node" ? 4096 : 1024
const hddTotal = s.type === "home-router" ? 2048 : 16384
const ramPct = 12 + (h % 72)
const hddPct = 8 + (h % 75)
return [s.id, {
cpu,
cpuHistory: Array.from({ length: 32 }, (_, i) =>
Math.max(1, Math.min(99, cpu + Math.round(Math.sin(i * 0.7 + h * 0.1) * 15))),
),
ramUsed: Math.round(ramTotal * ramPct / 100), ramTotal,
hddUsed: Math.round(hddTotal * hddPct / 100), hddTotal,
uptimeSeconds: (1 + h % 200) * 86400 + (h % 24) * 3600 + (h % 60) * 60,
temp: s.type !== "home-router" ? 34 + (h % 32) : undefined,
} satisfies SrvRes]
})
)
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 },
}
// ─── Positions ────────────────────────────────────────────────────────────────
const POS: Record<string, { x: number; y: number }> = {
srv1: { x: 500, y: 170 },
srv2: { x: 130, y: 75 },
srv3: { x: 820, y: 75 },
srv4: { x: 910, y: 265 },
srv5: { x: 700, y: 385 },
srv6: { x: 310, y: 345 },
srv7: { x: 125, y: 255 },
home1: { x: 270, y: 470 },
home2: { x: 790, y: 470 },
}
const WAN_SAT_POS: Record<string, { x: number; y: number }[]> = {
home1: [{ x: 155, y: 510 }, { x: 385, y: 510 }],
home2: [{ x: 665, y: 510 }, { x: 915, y: 510 }],
}
// ─── WAN→JH edges ────────────────────────────────────────────────────────────
interface WanJhEdge {
homeId: string; wanIdx: number; jhId: string
pingMs: number; dlMbps: number; active: boolean
}
const WAN_JH_EDGES: WanJhEdge[] = [
{ homeId: "home1", wanIdx: 0, jhId: "srv1", pingMs: 4, dlMbps: 480, active: true },
{ homeId: "home1", wanIdx: 0, jhId: "srv7", pingMs: 6, dlMbps: 480, active: true },
{ homeId: "home1", wanIdx: 1, jhId: "srv1", pingMs: 12, dlMbps: 95, active: false },
{ homeId: "home2", wanIdx: 0, jhId: "srv1", pingMs: 18, dlMbps: 280, active: true },
{ homeId: "home2", wanIdx: 0, jhId: "srv7", pingMs: 20, dlMbps: 278, active: false },
{ homeId: "home2", wanIdx: 1, jhId: "srv1", pingMs: 22, dlMbps: 185, active: false },
]
// ─── Tunnel probes ────────────────────────────────────────────────────────────
interface TunnelProbe { pingMs: number | null; dlMbps: number | null; ulMbps: number | null }
const TUNNEL_PROBES: Record<string, TunnelProbe> = {
gre1: { pingMs: 28, dlMbps: 614, ulMbps: 421 },
gre2: { pingMs: 41, dlMbps: 488, ulMbps: 352 },
gre3: { pingMs: 88, dlMbps: 143, ulMbps: 104 },
gre4: { pingMs: null, dlMbps: null, ulMbps: null },
gre5: { pingMs: 14, dlMbps: 578, ulMbps: 412 },
gre6: { pingMs: 92, dlMbps: 155, ulMbps: 112 },
}
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("-")
}
// ─── Filter ───────────────────────────────────────────────────────────────────
type FilterKey = "all" | "online" | "degraded" | "offline" | "jump-host" | "exit-node" | "home-router"
// ─── SVG sub-components ───────────────────────────────────────────────────────
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})`}>
<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 ? "timeout" : `${ping} мс`}
</text>
{hasSpeed && (
<text textAnchor="middle" y="10" fontSize="7" fill="#64748b" fontFamily="ui-monospace,monospace">
{`↓${dl}${ul}`}
</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, onClick, onDblClick, onHoverChange, onContextMenu, onMouseDown }: {
n: Server & { x: number; y: number }
isSel: boolean; isVis: boolean; isDragged: 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]
const lc = latencyColor(n.latency)
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>
<text textAnchor="middle" y="-4" fontSize={ts.r > 32 ? "13" : "11"} fontWeight="700"
fill="#f1f5f9" fontFamily="ui-monospace,monospace">{n.site}</text>
<text textAnchor="middle" y="12" fontSize="8.5" fill={lc} fontFamily="ui-monospace,monospace">
{n.latency == null ? "офлайн" : `${n.latency} мс`}
</text>
<text textAnchor="middle" y={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, onClose, onPan }: {
pan: { x: number; y: number }; zoom: number
nodes: (Server & { x: number; y: number })[]
greEdges: { tunnel: typeof greTunnels[0]; from: typeof nodes[0]; to: typeof nodes[0] }[]
satPos: Record<string, { x: number; y: number }[]>
onClose: () => void
onPan: (x: number, y: number) => void
}) {
const vpW = Math.min(W, W / zoom)
const vpH = Math.min(H, H / zoom)
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
const svgX = (e.clientX - rect.left) / rect.width * W
const svgY = (e.clientY - rect.top) / rect.height * H
onPan(svgX - vpW / 2, svgY - vpH / 2)
}
return (
<div className="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 */}
{WAN_JH_EDGES.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 */}
{servers.filter(s => s.type === "home-router").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() {
// ── 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 }[]>>({})
// ── Context menu ────────────────────────────────────────────────────────────
const [ctxMenu, setCtxMenu] = useState<CtxMenu | 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)
// ── 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) }
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]) // re-register when zoom/pan change so closure has fresh values
// ── 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] ?? (WAN_SAT_POS[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) }
}
// ── 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}`)
}
// ── Data ─────────────────────────────────────────────────────────────────
const nodes = servers.map(s => {
const pos = nodePositions[s.id] ?? POS[s.id] ?? { x: 0, y: 0 }
return { ...s, ...pos }
})
const nodeById = Object.fromEntries(nodes.map(n => [n.id, n]))
// Satellite positions merging defaults with overrides
const effectiveSatPos: Record<string, { x: number; y: number }[]> = {}
servers.filter(s => s.type === "home-router").forEach(r => {
effectiveSatPos[r.id] = (WAN_SAT_POS[r.id] ?? []).map((def, i) =>
satPositions[r.id]?.[i] ?? def
)
})
const greEdges = greTunnels.flatMap(t => {
const from = nodes.find(n => n.id === t.serverId)
const to = nodes.find(n => n.host === t.remoteAddress)
if (!from || !to || from.type === "home-router" || to.type === "home-router") return []
return [{ tunnel: t, from, to }]
})
// ── 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) {
setSelected(prev => prev?.id === s.id ? null : s)
setSelWanIdx(null)
setHoveredId(null)
}
function selectWan(s: Server, wanIdx: number) {
setSelected(s)
setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx)
}
const connectedTunnels = selected && selected.type !== "home-router"
? greTunnels.filter(t =>
t.serverId === selected.id ||
nodes.find(n => n.id === selected.id)?.host === t.remoteAddress
)
: []
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={
<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">
<span>
<span className="font-semibold text-emerald-400">{servers.filter(s => s.status === "online").length}</span> онлайн
</span>
<span>
<span className="font-semibold text-amber-400">{servers.filter(s => s.status === "degraded").length}</span> с проблемами
</span>
<span>
<span className="font-semibold text-red-400">{servers.filter(s => s.status === "offline").length}</span> оффлайн
</span>
<span>
<span className="font-semibold text-foreground">{greTunnels.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 dimmed = filter !== "all" && !isVisible(e.from) && !isVisible(e.to)
const probe = TUNNEL_PROBES[e.tunnel.id]
const pc = pingColor(probe?.pingMs ?? null)
const mx = (e.from.x + e.to.x) / 2
const my = (e.from.y + e.to.y) / 2
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}
/>
{showAnimDots && ts.dotOpacity > 0 && (
<circle r="3" fill={ts.stroke} opacity={ts.dotOpacity}>
<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 && probe && (
<PingBadge mx={mx} my={my} ping={probe.pingMs} dl={probe.dlMbps} ul={probe.ulMbps} color={pc} />
)}
</g>
)
})}
{/* ── Home Router → WAN satellite connectors ── */}
{servers.filter(s => s.type === "home-router").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 ── */}
{WAN_JH_EDGES.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 = (satPos.x + jh.x) / 2
const my = (satPos.y + jh.y) / 2
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 && (
<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}
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 ── */}
{servers.filter(s => s.type === "home-router").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 = WAN_JH_EDGES.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}
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 ── */}
{selected && (
<div className="border-l flex flex-col overflow-hidden shrink-0 bg-background" style={{ width: 300 }}>
<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 = WAN_JH_EDGES.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 = servers.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} мс
</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 = servers.find(s => s.host === t.remoteAddress && s.id !== selected.id)
|| servers.find(s => s.id === t.serverId && s.id !== selected.id)
const probe = TUNNEL_PROBES[t.id]
const pc = pingColor(probe?.pingMs ?? 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">
{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>
{probe?.pingMs != null && (
<div className="flex items-center justify-between pt-1 border-t border-border/30">
<span className="text-[9px] text-muted-foreground">ping / speed</span>
<span className="text-[10px] font-mono" style={{ color: pc }}>
{probe.pingMs} мс
{probe.dlMbps != null && (
<span className="text-[9px] text-muted-foreground ml-1.5">
{probe.dlMbps} {probe.ulMbps}
</span>
)}
</span>
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* Resources */}
{(() => {
const res = SERVER_RESOURCES[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-3">
Ресурсы
</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 */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground">HDD</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>
</div>
)}
</div>
{/* ── Context menu ── */}
{ctxMenu && <ContextMenu menu={ctxMenu} onClose={() => setCtxMenu(null)} />}
</div>
)
}