Files
MikrotikManager/components/dashboard/internet-path-map.tsx
T
DenozordecandCursor 5f29cfaf0f
Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped
fix(ui): выровнять chrome Frame и убрать Card-оболочки
Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 15:36:56 +07:00

418 lines
17 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
import { Flag } from "@/components/flag"
import { OpsPanel } from "@/components/ops-panel"
import { StatusBadge } from "@/components/status-badge"
import { cn } from "@/lib/utils"
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
import { Maximize2Icon, ZoomInIcon, ZoomOutIcon } from "lucide-react"
type Pt = { x: number; y: number }
type ServerKind = "home-router" | "jump-host" | "exit-node"
const W = 1060
const H = 420
const ZOOM_MIN = 0.2
const ZOOM_MAX = 6
const STATUS_STYLE = {
online: { fill: "#0f2d1f", stroke: "#4ade80", glow: "rgba(74,222,128,0.15)" },
degraded: { fill: "#2d1e06", stroke: "#fbbf24", glow: "rgba(251,191,36,0.15)" },
offline: { fill: "#2d0f0f", stroke: "#f87171", glow: "transparent" },
}
const TYPE_STYLE: Record<ServerKind, { label: string; fill: string; r: number }> = {
"home-router": { label: "HR", fill: "#16a34a", r: 40 },
"jump-host": { label: "JH", fill: "#7c3aed", r: 34 },
"exit-node": { label: "EN", fill: "#0369a1", r: 30 },
}
const WAN_COLORS = ["#0ea5e9", "#f97316", "#a855f7", "#ec4899", "#14b8a6"]
function pingColor(ms: number | null) {
if (ms == null) return "#f87171"
if (ms < 15) return "#4ade80"
if (ms < 50) return "#fbbf24"
return "#fb923c"
}
function edgeBadgePosition(x1: number, y1: number, x2: number, y2: number, t: number, normalPx: number): { mx: number; my: number } {
const px = x1 + (x2 - x1) * t
const py = y1 + (y2 - y1) * t
const dx = x2 - x1
const dy = y2 - y1
const len = Math.hypot(dx, dy) || 1
const nx = -dy / len
const ny = dx / len
return { mx: px + nx * normalPx, my: py + ny * normalPx }
}
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">
<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">
<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">
<Maximize2Icon className="size-3.5" />
</button>
</div>
)
}
function PingBadge({
mx,
my,
ping,
dl,
ul,
color,
monitored,
}: {
mx: number
my: number
ping: number | null
dl: number | null
ul: number | null
color: string
monitored?: boolean
}) {
const hasSpeed = dl != null || ul != null
const dlText = dl != null ? Math.round(dl) : "—"
const ulText = ul != null ? Math.round(ul) : "—"
return (
<g transform={`translate(${mx},${my})`}>
<rect x="-34" y={-20} width="68" height={hasSpeed ? 42 : 24} rx="4" fill="#060d1a" stroke={color} strokeWidth="0.7" opacity="0.92" />
<text textAnchor="middle" y={hasSpeed ? "-5" : "4"} fontSize="8.5" fontWeight="600" fill={color} fontFamily="ui-monospace,monospace">
{ping == null ? "—" : `${ping} мс`}
</text>
{hasSpeed && (
<text textAnchor="middle" y="8" fontSize="7" fill="#64748b" fontFamily="ui-monospace,monospace">
{`↓${dlText}${ulText}`}
</text>
)}
{monitored && (
<text textAnchor="middle" y={hasSpeed ? "17" : "14"} fontSize="6" fill="#38bdf8" fontFamily="ui-monospace,monospace">
mon
</text>
)}
</g>
)
}
function ServerNode({
pos,
type,
name,
site,
country,
status,
selected,
onClick,
}: {
pos: Pt
type: ServerKind
name: string
site: string
country: string
status: "online" | "offline" | "degraded"
selected: boolean
onClick: () => void
}) {
const ss = STATUS_STYLE[status]
const ts = TYPE_STYLE[type]
return (
<g transform={`translate(${pos.x},${pos.y})`} style={{ cursor: "pointer" }} onClick={onClick}>
{status === "online" && (
<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>
)}
{selected && (
<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={selected ? 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">{ts.label}</text>
</g>
<foreignObject x={-110} y={-22} width={220} height={28} style={{ overflow: "visible", pointerEvents: "none" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 6, width: "100%", height: "100%", fontFamily: "ui-monospace, monospace" }} {...({ xmlns: "http://www.w3.org/1999/xhtml" } as Record<string, string>)}>
<span style={{ flexShrink: 0, lineHeight: 0 }}>
<Flag code={country || "UN"} size={16} />
</span>
<span style={{ maxWidth: 170, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: 700, fontSize: 11, color: "#f1f5f9", lineHeight: 1.25 }}>
{site || name}
</span>
</div>
</foreignObject>
<text textAnchor="middle" y={ts.r + 18} fontSize="8.5" fill="#94a3b8" fontFamily="ui-monospace,monospace">
{name}
</text>
</g>
)
}
export function InternetPathMapCard({ model }: { model: InternetPathViewModel | null }) {
const [zoom, setZoom] = useState(1)
const [pan, setPan] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
const [selectedNode, setSelectedNode] = useState<"home" | "jh" | "en" | "wan" | null>("home")
const mapData = useMemo(() => {
if (!model) return null
const hop = model.currentHop ?? model.primaryHop ?? (
model.activeWanUplink && model.fallbackJumpHost && model.fallbackExitNode
? {
home: model.homeRouter,
wan: model.activeWanUplink,
jumpHost: model.fallbackJumpHost,
exitNode: model.fallbackExitNode,
wanJhMetrics: {
pingMs: null,
dlMbps: null,
ulMbps: null,
fromMonitoring: false,
},
jhExitMetrics: {
pingMs: null,
dlMbps: null,
ulMbps: null,
fromMonitoring: false,
},
}
: null
)
if (!hop) return null
const axisY = 220
const homePos = { x: 180, y: axisY }
const wanPos = { x: 400, y: axisY }
const jhPos = { x: 660, y: axisY }
const enPos = { x: 900, y: axisY }
const directPos = { x: 900, y: axisY + 110 }
const pathDiff =
model.primaryPath &&
model.currentPath &&
(model.primaryPath.wanId !== model.currentPath.wanId || model.primaryPath.jhId !== model.currentPath.jhId || model.primaryPath.exitId !== model.currentPath.exitId)
return { hop, homePos, wanPos, jhPos, enPos, directPos, pathDiff, directWan: model.directWan }
}, [model])
function onWheel(e: React.WheelEvent<SVGSVGElement>) {
e.preventDefault()
const rect = e.currentTarget.getBoundingClientRect()
const px = e.clientX - rect.left
const py = e.clientY - rect.top
const worldX = (px - pan.x) / zoom
const worldY = (py - pan.y) / zoom
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1
const next = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, zoom * factor))
setZoom(next)
setPan({ x: px - worldX * next, y: py - worldY * next })
}
return (
<OpsPanel
title="Internet path map"
description="Основной и текущий путь трафика HomeRouter → Internet"
headerRight={
<StatusBadge
status={
model?.pathState === "healthy"
? "online"
: model?.pathState === "failover"
? "degraded"
: "offline"
}
/>
}
contentClassName="px-5 pb-4"
>
{!model && (
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
Недостаточно данных для построения маршрута
</div>
)}
{model && mapData && (
<div
className="relative rounded-lg border border-[#1f2a3d] bg-gradient-to-b from-[#0a1424] to-[#060d17] overflow-hidden overscroll-contain"
onWheelCapture={(e) => {
// Когда курсор над картой, колесо управляет только картой (без прокрутки страницы).
e.preventDefault()
e.stopPropagation()
}}
>
<svg
viewBox={`0 0 ${W} ${H}`}
className={cn("w-full h-[320px] select-none", isDragging ? "cursor-grabbing" : "cursor-grab")}
onMouseDown={(e) => {
setIsDragging(true)
setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y })
}}
onMouseMove={(e) => {
if (!isDragging || !dragStart) return
setPan({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y })
}}
onMouseUp={() => { setIsDragging(false); setDragStart(null) }}
onMouseLeave={() => { setIsDragging(false); setDragStart(null) }}
onWheel={onWheel}
>
<g transform={`translate(${pan.x} ${pan.y}) scale(${zoom})`}>
<line x1={mapData.homePos.x} y1={mapData.homePos.y} x2={mapData.wanPos.x} y2={mapData.wanPos.y} stroke={WAN_COLORS[0]} strokeWidth="2.2" opacity="0.75" />
<line
x1={mapData.wanPos.x}
y1={mapData.wanPos.y}
x2={mapData.jhPos.x}
y2={mapData.jhPos.y}
stroke={mapData.pathDiff ? "#fbbf24" : "#4ade80"}
strokeWidth={3.2}
/>
<line
x1={mapData.jhPos.x}
y1={mapData.jhPos.y}
x2={mapData.enPos.x}
y2={mapData.enPos.y}
stroke={mapData.pathDiff ? "#fbbf24" : "#4ade80"}
strokeWidth={3.2}
/>
{mapData.directWan.enabled && (
<line
x1={mapData.wanPos.x}
y1={mapData.wanPos.y}
x2={mapData.directPos.x}
y2={mapData.directPos.y}
stroke="#38bdf8"
strokeWidth="2.4"
opacity="0.9"
/>
)}
<g transform={`translate(${mapData.wanPos.x},${mapData.wanPos.y})`} onClick={() => setSelectedNode("wan")} style={{ cursor: "pointer" }}>
<circle r={24} fill={`${WAN_COLORS[0]}1a`} stroke={WAN_COLORS[0]} strokeWidth="2" />
<path d="M -5 2 Q 0 -5 5 2" fill="none" stroke={WAN_COLORS[0]} strokeWidth="1.5" strokeLinecap="round" />
<path d="M -8 5 Q 0 -10 8 5" fill="none" stroke={WAN_COLORS[0]} strokeWidth="1" strokeLinecap="round" opacity="0.6" />
<circle cx="0" cy="4" r="2" fill={WAN_COLORS[0]} />
<text textAnchor="middle" y="36" fontSize="8" fontWeight="700" fill={WAN_COLORS[0]} fontFamily="ui-monospace,monospace">
{mapData.hop.wan.name}
</text>
</g>
<ServerNode
pos={mapData.homePos}
type="home-router"
name={mapData.hop.home.name}
site={mapData.hop.home.site}
country={mapData.hop.home.country}
status={mapData.hop.home.status}
selected={selectedNode === "home"}
onClick={() => setSelectedNode("home")}
/>
{mapData.directWan.enabled && (
<g transform={`translate(${mapData.directPos.x},${mapData.directPos.y})`}>
<circle r={22} fill="#08243a" stroke="#38bdf8" strokeWidth="1.8" />
<text textAnchor="middle" y="4" fontSize="10" fontWeight="700" fill="#38bdf8">NET</text>
<text textAnchor="middle" y="34" fontSize="8" fill="#7dd3fc" fontFamily="ui-monospace,monospace">
{mapData.directWan.provider ?? "Direct WAN"}
</text>
</g>
)}
<ServerNode
pos={mapData.jhPos}
type="jump-host"
name={mapData.hop.jumpHost.name}
site={mapData.hop.jumpHost.site}
country={mapData.hop.jumpHost.country}
status={mapData.hop.jumpHost.status}
selected={selectedNode === "jh"}
onClick={() => setSelectedNode("jh")}
/>
<ServerNode
pos={mapData.enPos}
type="exit-node"
name={mapData.hop.exitNode.name}
site={mapData.hop.exitNode.site}
country={mapData.hop.exitNode.country}
status={mapData.hop.exitNode.status}
selected={selectedNode === "en"}
onClick={() => setSelectedNode("en")}
/>
<PingBadge
{...edgeBadgePosition(mapData.wanPos.x, mapData.wanPos.y, mapData.jhPos.x, mapData.jhPos.y, 0.52, -24)}
ping={mapData.hop.wanJhMetrics.pingMs}
dl={mapData.hop.wanJhMetrics.dlMbps}
ul={mapData.hop.wanJhMetrics.ulMbps}
color={pingColor(mapData.hop.wanJhMetrics.pingMs)}
monitored={mapData.hop.wanJhMetrics.fromMonitoring}
/>
<PingBadge
{...edgeBadgePosition(mapData.jhPos.x, mapData.jhPos.y, mapData.enPos.x, mapData.enPos.y, 0.5, -24)}
ping={mapData.hop.jhExitMetrics.pingMs}
dl={mapData.hop.jhExitMetrics.dlMbps}
ul={mapData.hop.jhExitMetrics.ulMbps}
color={pingColor(mapData.hop.jhExitMetrics.pingMs)}
monitored={mapData.hop.jhExitMetrics.fromMonitoring}
/>
{mapData.directWan.enabled && (
<PingBadge
{...edgeBadgePosition(mapData.wanPos.x, mapData.wanPos.y, mapData.directPos.x, mapData.directPos.y, 0.55, -22)}
ping={null}
dl={null}
ul={null}
color="#38bdf8"
monitored={false}
/>
)}
</g>
</svg>
<ZoomControls
zoom={zoom}
onZoomOut={() => setZoom((z) => Math.max(ZOOM_MIN, z * 0.9))}
onZoomIn={() => setZoom((z) => Math.min(ZOOM_MAX, z * 1.1))}
onFit={() => { setZoom(1); setPan({ x: 0, y: 0 }) }}
/>
</div>
)}
{model && !mapData && (
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
Нет полного набора узлов для визуализации пути
</div>
)}
{model && (
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
<div className={cn("rounded-md border p-2", model.pathState === "failover" ? "border-amber-500/40 bg-amber-500/5" : "border-emerald-500/40 bg-emerald-500/5")}>
<p className="font-medium">Primary path</p>
<p className="text-muted-foreground mt-1">{model.primaryPath?.reason ?? "Не определен"}</p>
</div>
<div className={cn("rounded-md border p-2", model.pathState === "failover" ? "border-amber-500/40 bg-amber-500/5" : "border-emerald-500/40 bg-emerald-500/5")}>
<p className="font-medium">Current path</p>
<p className="text-muted-foreground mt-1">{model.currentPath?.reason ?? "Не определен"}</p>
</div>
{model.directWan.enabled && (
<div className="rounded-md border p-2 border-sky-500/40 bg-sky-500/5 md:col-span-2">
<p className="font-medium">Direct WAN path</p>
<p className="text-muted-foreground mt-1">
{`gateway: ${model.directWan.gateway ?? "—"} · iface: ${model.directWan.iface ?? "—"} · dhcp ip: ${model.directWan.leasedIp ?? "—"} · isp: ${model.directWan.provider ?? "—"}`}
</p>
</div>
)}
</div>
)}
</OpsPanel>
)
}