"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 = { "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 (
{Math.round(zoom * 100)}%
) } 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 ( {ping == null ? "—" : `${ping} мс`} {hasSpeed && ( {`↓${dlText} ↑${ulText}`} )} {monitored && ( mon )} ) } 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 ( {status === "online" && ( )} {selected && ( )} {ts.label}
)}> {site || name}
{name}
) } 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) { 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 (
Internet path map

Основной и текущий путь трафика HomeRouter → Internet

{!model && (
Недостаточно данных для построения маршрута
)} {model && mapData && (
{ // Когда курсор над картой, колесо управляет только картой (без прокрутки страницы). e.preventDefault() e.stopPropagation() }} > { 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} > {mapData.directWan.enabled && ( )} setSelectedNode("wan")} style={{ cursor: "pointer" }}> {mapData.hop.wan.name} setSelectedNode("home")} /> {mapData.directWan.enabled && ( NET {mapData.directWan.provider ?? "Direct WAN"} )} setSelectedNode("jh")} /> setSelectedNode("en")} /> {mapData.directWan.enabled && ( )} 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 }) }} />
)} {model && !mapData && (
Нет полного набора узлов для визуализации пути
)} {model && (

Primary path

{model.primaryPath?.reason ?? "Не определен"}

Current path

{model.currentPath?.reason ?? "Не определен"}

{model.directWan.enabled && (

Direct WAN path

{`gateway: ${model.directWan.gateway ?? "—"} · iface: ${model.directWan.iface ?? "—"} · dhcp ip: ${model.directWan.leasedIp ?? "—"} · isp: ${model.directWan.provider ?? "—"}`}

)}
)}
) }