1480 lines
68 KiB
TypeScript
1480 lines
68 KiB
TypeScript
"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>
|
||
)
|
||
}
|