feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

+94
View File
@@ -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>
)
}
+401
View File
@@ -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>
)
}
+27
View File
@@ -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>
}