feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
interface BandwidthChartProps {
|
||||
rx: number[]
|
||||
tx: number[]
|
||||
}
|
||||
|
||||
export function BandwidthChart({ rx, tx }: BandwidthChartProps) {
|
||||
const W = 600, H = 200
|
||||
const pad = { l: 44, r: 12, t: 12, b: 28 }
|
||||
const iw = W - pad.l - pad.r
|
||||
const ih = H - pad.t - pad.b
|
||||
|
||||
const maxVal = Math.max(...rx, ...tx) * 1.15
|
||||
const xAt = (i: number, n: number) => pad.l + (i / (n - 1)) * iw
|
||||
const yAt = (v: number) => pad.t + (1 - v / maxVal) * ih
|
||||
|
||||
const areaPath = (arr: number[]) => {
|
||||
const top = arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ")
|
||||
return `M ${pad.l},${pad.t + ih} L ${top} L ${pad.l + iw},${pad.t + ih} Z`
|
||||
}
|
||||
const linePath = (arr: number[]) =>
|
||||
arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: 200, display: "block" }}>
|
||||
<defs>
|
||||
<linearGradient id="rx-grad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" style={{ stopColor: "var(--chart-rx)", stopOpacity: 0.25 }} />
|
||||
<stop offset="100%" style={{ stopColor: "var(--chart-rx)", stopOpacity: 0 }} />
|
||||
</linearGradient>
|
||||
<linearGradient id="tx-grad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" style={{ stopColor: "var(--chart-tx)", stopOpacity: 0.20 }} />
|
||||
<stop offset="100%" style={{ stopColor: "var(--chart-tx)", stopOpacity: 0 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((p, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={pad.l} x2={W - pad.r}
|
||||
y1={pad.t + ih * p} y2={pad.t + ih * p}
|
||||
style={{ stroke: "var(--border)" }}
|
||||
strokeDasharray={p === 1 ? "0" : "2 4"}
|
||||
/>
|
||||
<text
|
||||
x={pad.l - 8} y={pad.t + ih * p + 4}
|
||||
textAnchor="end" fontSize="10"
|
||||
style={{ fill: "var(--muted-foreground)" }}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
{Math.round(maxVal * (1 - p))}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
<path d={areaPath(rx)} fill="url(#rx-grad)" />
|
||||
<polyline points={linePath(rx)} fill="none"
|
||||
style={{ stroke: "var(--chart-rx)" }}
|
||||
strokeWidth="1.6" strokeLinejoin="round" />
|
||||
|
||||
<path d={areaPath(tx)} fill="url(#tx-grad)" />
|
||||
<polyline points={linePath(tx)} fill="none"
|
||||
style={{ stroke: "var(--chart-tx)" }}
|
||||
strokeWidth="1.6" strokeLinejoin="round" />
|
||||
|
||||
{[0, 15, 30, 45, 59].map((i) => (
|
||||
<text
|
||||
key={i} x={xAt(i, 60)} y={H - 6}
|
||||
textAnchor="middle" fontSize="10"
|
||||
style={{ fill: "var(--muted-foreground)" }}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
-{60 - i}м
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-4 pt-2 pl-11">
|
||||
{[
|
||||
{ key: "rx", label: "RX ↓", color: "var(--chart-rx)" },
|
||||
{ key: "tx", label: "TX ↑", color: "var(--chart-tx)" },
|
||||
].map(({ key, label, color }) => (
|
||||
<div key={key} className="flex items-center gap-1.5 text-xs">
|
||||
<span className="w-3 h-0.5 rounded" style={{ background: color }} />
|
||||
<span className="font-mono text-foreground">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DASHBOARD_SOURCES, type DashboardSource, type DashboardSources } from "@/lib/dashboard-source-state"
|
||||
|
||||
export function DashboardDataStatus({ sources, refreshing, onRefresh }: {
|
||||
sources: DashboardSources
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const failed = Object.values(sources).filter(source => source.error)
|
||||
const summary = failed.length ? `Не обновлены источники: ${failed.length}` : "Состояние источников"
|
||||
return <div className="flex flex-col gap-2">
|
||||
{failed.length > 0 && <Alert variant="destructive">
|
||||
<AlertTitle>{summary}</AlertTitle>
|
||||
<AlertDescription>Последние полученные данные сохранены. Если источник ещё не загрузился, его показатели неизвестны.</AlertDescription>
|
||||
</Alert>}
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<details className="min-w-0 text-sm" open={failed.length > 0 || undefined}>
|
||||
<summary className="cursor-pointer">Источники и время получения</summary>
|
||||
<ul className="mt-2 flex flex-col gap-1 text-xs">
|
||||
{(Object.keys(DASHBOARD_SOURCES) as DashboardSource[]).map(key => {
|
||||
const source = sources[key]
|
||||
return <li key={key} data-source={key} className={`break-words ${source?.error ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{DASHBOARD_SOURCES[key]}: {source?.error ? `ошибка обновления — ${source.error}. ` : ""}
|
||||
{source?.receivedAt != null ? <>Получено <time dateTime={new Date(source.receivedAt).toISOString()}>{new Date(source.receivedAt).toLocaleString("ru-RU")}</time>{source.error ? " · данные могут быть устаревшими" : ""}</> : "Успешных загрузок ещё нет"}
|
||||
{source?.sampledAt && Number.isFinite(Date.parse(source.sampledAt)) && <> · замер <time dateTime={source.sampledAt}>{new Date(source.sampledAt).toLocaleString("ru-RU")}</time></>}
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
<div className="flex items-center gap-2">
|
||||
<span role="status" className="text-xs text-muted-foreground">{refreshing ? "Обновление данных…" : "Обновление завершено"}</span>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onRefresh} disabled={refreshing}>Обновить данные</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from "@/components/reui/timeline"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { EventItem } from "@mmapp/contracts/events"
|
||||
|
||||
function formatEventAge(iso: string): string {
|
||||
const ts = Date.parse(iso)
|
||||
if (!Number.isFinite(ts)) return "—"
|
||||
const diffMs = Math.max(0, Date.now() - ts)
|
||||
const minutes = Math.floor(diffMs / 60_000)
|
||||
if (minutes < 1) return "сейчас"
|
||||
if (minutes < 60) return `${minutes}м`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}ч`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}д`
|
||||
}
|
||||
|
||||
const LEVEL_DOT: Record<EventItem["level"], string> = {
|
||||
critical: "border-destructive bg-destructive/20 group-data-completed/timeline-item:border-destructive",
|
||||
warning: "border-warning bg-warning/20 group-data-completed/timeline-item:border-warning",
|
||||
info: "border-info bg-info/20 group-data-completed/timeline-item:border-info",
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact activity timeline.
|
||||
* Preview: https://reui.io/preview/base/timeline-3
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
*/
|
||||
export function DashboardEventsTimeline({
|
||||
events,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
events: EventItem[]
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
}) {
|
||||
if (loading && events.length === 0) {
|
||||
return <p className="text-muted-foreground px-5 py-6 text-sm">Загрузка событий…</p>
|
||||
}
|
||||
if (error && events.length === 0) {
|
||||
return (
|
||||
<div className="px-5 py-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>События недоступны</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground px-5 py-6 text-sm">
|
||||
Событий пока нет.{" "}
|
||||
<Link href="/alerts" className="underline underline-offset-2">
|
||||
Оповещения
|
||||
</Link>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Timeline defaultValue={events.length} className="px-5 py-4">
|
||||
{events.map((event, index) => (
|
||||
<TimelineItem key={event.id} step={index + 1}>
|
||||
<TimelineHeader>
|
||||
<TimelineSeparator />
|
||||
<TimelineDate>{formatEventAge(event.createdAt)}</TimelineDate>
|
||||
<TimelineTitle className="text-[13px] leading-tight">{event.title}</TimelineTitle>
|
||||
<TimelineIndicator className={cn(LEVEL_DOT[event.level])} />
|
||||
</TimelineHeader>
|
||||
<TimelineContent className="text-xs leading-snug">{event.message}</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Flag } from "@/components/flag"
|
||||
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 = {
|
||||
unknown: { fill: "var(--muted)", stroke: "var(--muted-foreground)", glow: "transparent" },
|
||||
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" | "unknown"
|
||||
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 InternetPathMapCanvas({ 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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{!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">Рекомендация оптимизатора</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">Наблюдаемый default route</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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
||||
import { InternetPathMapCanvas } from "@/components/dashboard/internet-path-map"
|
||||
import { InternetPathSummary } from "@/components/dashboard/internet-path-summary"
|
||||
|
||||
const PATH_MAP_OPEN_LS = "mm:dashboard-path-map-open"
|
||||
|
||||
function readMapOpen(): boolean {
|
||||
if (typeof window === "undefined") return true
|
||||
try {
|
||||
const raw = localStorage.getItem(PATH_MAP_OPEN_LS)
|
||||
if (raw === "0") return false
|
||||
if (raw === "1") return true
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function InternetPathPanel({
|
||||
model,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
model: InternetPathViewModel | null
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
}) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setOpen(readMapOpen())
|
||||
setHydrated(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
setOpen(next)
|
||||
try {
|
||||
localStorage.setItem(PATH_MAP_OPEN_LS, next ? "1" : "0")
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OpsPanel
|
||||
title="Internet path"
|
||||
description="Home → WAN → JH → Exit"
|
||||
headerRight={
|
||||
<Link href="/network-map" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
|
||||
Карта сети →
|
||||
</Link>
|
||||
}
|
||||
contentClassName="flex flex-col gap-3 px-5 pb-4"
|
||||
>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Не удалось загрузить путь</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{loading && !model ? (
|
||||
<div className="h-20 animate-pulse rounded-md bg-muted/40" />
|
||||
) : (
|
||||
<InternetPathSummary model={model} />
|
||||
)}
|
||||
|
||||
<Collapsible open={hydrated ? open : true} onOpenChange={handleOpenChange}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-7 w-fit gap-1.5 text-xs")}
|
||||
>
|
||||
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
|
||||
{open ? "Скрыть карту" : "Показать карту"}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<InternetPathMapCanvas model={model} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</OpsPanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { Flag } from "@/components/flag"
|
||||
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
||||
import type { ServerStatus } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ArrowRightIcon, HomeIcon, RadioIcon, ServerIcon, GlobeIcon } from "lucide-react"
|
||||
|
||||
function pathStateToServerStatus(state: InternetPathViewModel["pathState"]): ServerStatus {
|
||||
if (state === "healthy") return "online"
|
||||
if (state === "failover" || state === "degraded") return "degraded"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function HopChip({
|
||||
label,
|
||||
name,
|
||||
country,
|
||||
icon,
|
||||
iconClassName,
|
||||
}: {
|
||||
label: string
|
||||
name: string
|
||||
country?: string
|
||||
icon: ReactNode
|
||||
iconClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 px-2 py-1.5">
|
||||
<IconTile variant="elevated" size="sm" className={cn("shrink-0", iconClassName)} aria-hidden="true">
|
||||
{icon}
|
||||
</IconTile>
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">{label}</p>
|
||||
<p className="flex items-center gap-1 truncate text-sm font-medium">
|
||||
{country ? <Flag code={country} className="shrink-0" /> : null}
|
||||
<span className="truncate">{name}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact live path strip (Frame-friendly). Canvas lives separately.
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export function InternetPathSummary({ model }: { model: InternetPathViewModel | null }) {
|
||||
if (!model) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Недостаточно данных для пути. Добавьте home-router и проверьте{" "}
|
||||
<Link href="/network-map" className="underline underline-offset-2">
|
||||
карту сети
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const hop = model.currentHop ?? model.primaryHop
|
||||
const wanName = hop?.wan.name ?? model.activeWanUplink?.name ?? "WAN"
|
||||
const wanIsp = hop?.wan.isp ?? model.activeWanUplink?.isp ?? "—"
|
||||
const ping = hop?.wanJhMetrics.pingMs
|
||||
const dl = hop?.wanJhMetrics.dlMbps
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={pathStateToServerStatus(model.pathState)} />
|
||||
{model.pathState === "failover" ? (
|
||||
<Badge variant="warning-light" size="sm">failover</Badge>
|
||||
) : null}
|
||||
{ping != null ? (
|
||||
<Badge variant="outline" size="sm" className="tabular-nums">
|
||||
{ping} мс
|
||||
</Badge>
|
||||
) : null}
|
||||
{dl != null ? (
|
||||
<Badge variant="outline" size="sm" className="tabular-nums">
|
||||
{Math.round(dl)} ↓ Мбит/с
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">JH/Exit — схема кандидатов. Default route подтверждает только WAN. Метрики — последние подтверждённые пробы участков.</p>
|
||||
<div className="flex flex-col gap-2 @3xl:flex-row @3xl:items-center">
|
||||
<HopChip
|
||||
label="Home"
|
||||
name={model.homeRouter.name}
|
||||
country={model.homeRouter.country}
|
||||
icon={<HomeIcon />}
|
||||
iconClassName="text-success"
|
||||
/>
|
||||
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
|
||||
<HopChip
|
||||
label="WAN"
|
||||
name={`${wanName} · ${wanIsp}`}
|
||||
icon={<RadioIcon />}
|
||||
iconClassName="text-info"
|
||||
/>
|
||||
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
|
||||
<HopChip
|
||||
label="JH"
|
||||
name={hop?.jumpHost.name ?? model.fallbackJumpHost?.name ?? "—"}
|
||||
country={hop?.jumpHost.country ?? model.fallbackJumpHost?.country}
|
||||
icon={<ServerIcon />}
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
|
||||
<HopChip
|
||||
label="Exit"
|
||||
name={hop?.exitNode.name ?? model.fallbackExitNode?.name ?? "—"}
|
||||
country={hop?.exitNode.country ?? model.fallbackExitNode?.country}
|
||||
icon={<GlobeIcon />}
|
||||
iconClassName="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{model.currentPath?.reason ?? "Текущий маршрут не подтверждён"}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
import {CartesianGrid,Line,LineChart,XAxis,YAxis} from "recharts"
|
||||
import {ChartContainer,ChartTooltip,ChartTooltipContent,type ChartConfig} from "@/components/ui/chart"
|
||||
import {formatRangeAgo} from "@/lib/fmt-rate"
|
||||
const PALETTE=["var(--chart-line-1)","var(--chart-line-2)","var(--chart-line-3)","var(--chart-line-4)","var(--chart-1)","var(--chart-2)","var(--chart-3)","var(--chart-4)"]
|
||||
const LEGACY_LABELS:Record<string,string>={msk:"MSK",spb:"SPB",fra:"FRA",ams:"AMS"}
|
||||
interface LatencyChartProps {series:Record<string,(number|null)[]>;labels?:Record<string,string>}
|
||||
/** Reuse the installed ChartContainer / multi-line composition of ReUI chart-23. */
|
||||
export function LatencyChart({series,labels:labelsProp}:LatencyChartProps){
|
||||
const entries=Object.entries(series).filter(([,values])=>values.some(v=>typeof v==="number"&&Number.isFinite(v)))
|
||||
const count=Math.max(1,...entries.map(([,values])=>values.length))
|
||||
const data=Array.from({length:count},(_,i)=>({time:formatRangeAgo(Math.round((1-i/Math.max(count-1,1))*60)),...Object.fromEntries(entries.map(([key,values])=>[key,typeof values[i]==="number"&&Number.isFinite(values[i])?values[i]:null]))}))
|
||||
const label=(key:string)=>labelsProp?.[key]??LEGACY_LABELS[key]??key.replace(/^src-/,"")
|
||||
const config=Object.fromEntries(entries.map(([key],index)=>[key,{label:label(key),color:PALETTE[index%PALETTE.length]}])) satisfies ChartConfig
|
||||
const dot=(key:string)=>function IsolatedDot({cx,cy,index}:{cx?:number;cy?:number;index?:number}){const values=series[key];if(index==null||cx==null||cy==null||values[index]==null||values[index-1]!=null||values[index+1]!=null)return <g/>;return <circle cx={cx} cy={cy} r={3} fill={config[key].color} data-latency-isolated="true"/>}
|
||||
if(!entries.length)return <p role="status" className="py-10 text-center text-sm text-muted-foreground">Нет измеренной истории RTT</p>
|
||||
return <div role="region" aria-label="История RTT по источникам" className="flex flex-col gap-3">
|
||||
<ChartContainer config={config} className="aspect-auto h-[220px] w-full"><LineChart data={data} margin={{top:8,right:8,left:0,bottom:0}}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="4 8" stroke="var(--border)"/>
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} tick={{fontSize:11}} tickMargin={8} minTickGap={16}/>
|
||||
<YAxis axisLine={false} tickLine={false} tick={{fontSize:11}} width={52} tickFormatter={(v:number)=>String(v)+" мс"}/>
|
||||
<ChartTooltip content={<ChartTooltipContent formatter={(value,name)=><div className="flex min-w-0 items-center gap-2 text-xs"><span>{config[String(name)]?.label??name}</span><span className="font-mono">{typeof value==="number"?String(value)+" мс":"—"}</span></div>}/>}/>
|
||||
{entries.map(([key])=><Line key={key} dataKey={key} type="linear" stroke={config[key].color} strokeWidth={1.6} connectNulls={false} dot={dot(key)} isAnimationActive={false}/>)}
|
||||
</LineChart></ChartContainer>
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">{entries.map(([key])=><span key={key} data-latency-source={key} className="flex items-center gap-1.5 text-xs"><span aria-hidden className="h-0.5 w-3 rounded" style={{background:config[key].color}}/><span className="text-foreground">{label(key)}</span></span>)}</div>
|
||||
</div>
|
||||
}
|
||||
Reference in new issue
Block a user