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.
412 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|