Files
MikrotikManager/components/dashboard/internet-path-map.tsx
T
Denozordec b9a75b6831 feat: implement internet path functionality with backend support
Added internet path settings and snapshot management to the application. This includes new database tables for internet path settings and snapshots, API routes for fetching and managing internet path data, and integration into the dashboard and data collection pages. Enhanced the scheduler to support internet path jobs, ensuring regular data collection and updates. Updated relevant types and interfaces to accommodate the new functionality.
2026-05-08 00:40:41 +07:00

412 lines
17 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
import { Flag } from "@/components/flag"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
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,
}
: 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 (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<CardTitle className="text-base">Internet path map</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5 truncate">
Основной и текущий путь трафика HomeRouter Internet
</p>
</div>
<StatusBadge
status={
model?.pathState === "healthy"
? "online"
: model?.pathState === "failover"
? "degraded"
: "offline"
}
/>
</div>
</CardHeader>
<CardContent className="pt-0">
{!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>
)}
</CardContent>
</Card>
)
}