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
Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell. Co-authored-by: Cursor <cursoragent@cursor.com>
418 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|