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

+870
View File
@@ -0,0 +1,870 @@
"use client"
import { Suspense, useState, useMemo, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
import { useBgpUrlState } from "@/hooks/use-bgp-url-state"
import { QueryFilters } from "@/components/data-grids/query-filters"
import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
import { bgpCatalogSchema, bgpServersSchema, type BackendBgpSession, type BgpFailure } from "@/lib/bgp/response"
import { fmtBgpNum, sumBgpCounters } from "@/lib/bgp/helpers"
import { BGP_AS_NAMES } from "@/lib/bgp/types"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { Button } from "@/components/ui/button"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { cn } from "@/lib/utils"
import {
RefreshCwIcon, DownloadIcon,
ActivityIcon, BarChart3Icon,
ArrowDownIcon, ServerIcon,
AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { servers as mockServers, type Server } from "@/lib/data"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
// ─── types ────────────────────────────────────────────────────────────────────
type BgpSession = BgpSessionRow
type BgpTab = "sessions" | "routers" | "analytics"
const AS_NAMES = BGP_AS_NAMES
// ─── mock data ────────────────────────────────────────────────────────────────
const SESSIONS: BgpSession[] = [
// ── srv1 (mt-msk-core-01) ────────────────────────────────────────────
{
id: "s1", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "185.1.1.1", remoteAs: 8359, localAs: 65001,
routerId: "185.1.1.1", description: "МТС — upstream transit",
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
uptime: "14д 6ч 22м", holdTime: 90, keepalive: 30,
prefixesRx: 280241, prefixesTx: 42, prefixesActive: 277894,
inputMessages: 1842204, outputMessages: 14412,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "ADD-PATH"],
lastError: null,
},
{
id: "s2", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "77.88.8.1", remoteAs: 13238, localAs: 65001,
routerId: "77.88.44.1", description: "Яндекс — IX peering",
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
uptime: "12д 3ч 11м", holdTime: 90, keepalive: 30,
prefixesRx: 11840, prefixesTx: 42, prefixesActive: 11840,
inputMessages: 184220, outputMessages: 14100,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
{
id: "s3", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "10.200.0.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.2", description: "iBGP → SPB-EDGE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "8д 14ч 5м", holdTime: 90, keepalive: 30,
prefixesRx: 620, prefixesTx: 280283, prefixesActive: 620,
inputMessages: 48210, outputMessages: 512800,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Route-Target Constraint"],
lastError: null,
},
{
id: "s4", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "10.200.1.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.3", description: "iBGP → FRA-EDGE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "12д 2ч 18м", holdTime: 90, keepalive: 30,
prefixesRx: 512, prefixesTx: 280283, prefixesActive: 512,
inputMessages: 41022, outputMessages: 488200,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
{
id: "s5", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "10.200.2.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.4", description: "iBGP → AMS-EDGE",
state: "Active", type: "iBGP", afi: "IPv4 Unicast",
uptime: null, holdTime: 90, keepalive: 30,
prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
inputMessages: 0, outputMessages: 0,
capabilities: [],
lastError: "Hold timer expired",
},
// ── srv2 (mt-spb-edge-01) ────────────────────────────────────────────
{
id: "s6", serverId: "srv2", serverLabel: "mt-spb-edge-01", serverSite: "SPB",
peerIp: "195.54.55.1", remoteAs: 12389, localAs: 65001,
routerId: "195.54.55.1", description: "Ростелеком — upstream",
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
uptime: "9д 1ч 44м", holdTime: 90, keepalive: 30,
prefixesRx: 192440, prefixesTx: 42, prefixesActive: 191002,
inputMessages: 982440, outputMessages: 12200,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
{
id: "s7", serverId: "srv2", serverLabel: "mt-spb-edge-01", serverSite: "SPB",
peerIp: "10.200.0.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "8д 14ч 3м", holdTime: 90, keepalive: 30,
prefixesRx: 280283, prefixesTx: 620, prefixesActive: 277000,
inputMessages: 512800, outputMessages: 48210,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Route-Target Constraint"],
lastError: null,
},
// ── srv3 (mt-fra-edge-01) ────────────────────────────────────────────
{
id: "s8", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
peerIp: "91.108.4.1", remoteAs: 24940, localAs: 65001,
routerId: "91.108.4.1", description: "Hetzner — upstream",
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
uptime: "21д 7ч 12м", holdTime: 90, keepalive: 30,
prefixesRx: 218820, prefixesTx: 42, prefixesActive: 215400,
inputMessages: 1184200, outputMessages: 11800,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Graceful Restart"],
lastError: null,
},
{
id: "s9", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
peerIp: "193.188.128.1", remoteAs: 6777, localAs: 65001,
routerId: "193.188.128.1", description: "AMS-IX — peering (idle)",
state: "Idle", type: "eBGP", afi: "IPv4 Unicast",
uptime: null, holdTime: 90, keepalive: 30,
prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
inputMessages: 821, outputMessages: 412,
capabilities: [],
lastError: "Administratively down",
},
{
id: "s10", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
peerIp: "10.200.1.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "12д 2ч 14м", holdTime: 90, keepalive: 30,
prefixesRx: 280283, prefixesTx: 512, prefixesActive: 278100,
inputMessages: 488200, outputMessages: 41022,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
// ── srv6 (mt-ams-test-01) ────────────────────────────────────────────
{
id: "s11", serverId: "srv6", serverLabel: "mt-ams-test-01", serverSite: "AMS",
peerIp: "80.249.208.1", remoteAs: 6777, localAs: 65001,
routerId: "0.0.0.0", description: "AMS-IX — negotiating",
state: "OpenSent", type: "eBGP", afi: "IPv4 Unicast",
uptime: null, holdTime: 90, keepalive: 30,
prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
inputMessages: 2, outputMessages: 1,
capabilities: [],
lastError: null,
},
{
id: "s12", serverId: "srv6", serverLabel: "mt-ams-test-01", serverSite: "AMS",
peerIp: "10.200.5.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "5д 9ч 17м", holdTime: 90, keepalive: 30,
prefixesRx: 280283, prefixesTx: 14, prefixesActive: 276000,
inputMessages: 184200, outputMessages: 4100,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
]
// ─── visual helpers ───────────────────────────────────────────────────────────
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
Unknown: { bg: "bg-muted", text: "text-muted-foreground", dot: "bg-muted-foreground", label: "Неизвестно" },
Established: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", label: "Established" },
Active: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", dot: "bg-amber-500", label: "Active" },
Idle: { bg: "bg-slate-500/10", text: "text-slate-500 dark:text-slate-400", dot: "bg-slate-500", label: "Idle" },
Connect: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400", dot: "bg-blue-500", label: "Connect" },
OpenSent: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenSent" },
OpenConfirm: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenConfirm" },
}
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
Unknown: { bg: "bg-muted", text: "text-muted-foreground" },
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
}
function TypeBadge({ type }: { type: BgpType }) {
const s = TYPE_STYLE[type]
return (
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
s.bg, s.text, "border-current/20")}>
{type === "Unknown" ? "Тип неизвестен" : type}
</span>
)
}
const fmtNum = fmtBgpNum
// ─── backend mapping ──────────────────────────────────────────────────────────
type BackendServer = ReturnType<typeof bgpServersSchema.parse>[number]
type InventoryServer = Server & { monitoringUnknown?: boolean }
function mapBackendServer(s: BackendServer): InventoryServer {
return { id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", site: s.site ?? "",
country: s.country || "UN", asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled,
status: s.status ?? "offline", monitoringUnknown: s.status == null, latency: s.latency ?? null, sessions: 0 }
}
function backendToFrontend(b: BackendBgpSession): BgpSession {
return {
id: `${b.serverId}-${b.id}`,
serverId: String(b.serverId),
serverLabel: b.serverName,
serverSite: b.serverSite,
peerIp: b.peerIp,
remoteAs: b.remoteAs,
localAs: b.localAs,
routerId: b.remoteId || b.localId,
description: b.name,
state: Object.hasOwn(STATE_STYLE, b.state) ? b.state as BgpState : "Unknown",
type: b.type,
afi: b.afi ?? "Неизвестно",
uptime: b.uptime,
holdTime: b.holdTime,
keepalive: b.keepalive,
prefixesRx: b.prefixesRx,
prefixesTx: b.prefixesTx,
prefixesActive: null,
inputMessages: b.inputMessages,
outputMessages: b.outputMessages,
capabilities: b.capabilities,
lastError: b.lastError,
}
}
// ─── sessions tab ─────────────────────────────────────────────────────────────
const BGP_FILTER_ACCESSORS = {
state: (s: BgpSession) => s.state,
type: (s: BgpSession) => s.type,
afi: (s: BgpSession) => s.afi,
}
function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession }: { sessions: BgpSession[]; emptyTitle?: string; selectedSessionId: string | null; onSelectSession: (id: string | null) => void }) {
const { search, setSearch, stateFilter, setStateFilter, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset } = useBgpUrlState()
const fields = useMemo(() => BGP_FILTER_FIELDS.map(field => field.id === "afi" ? { ...field, options: Array.from(new Set(sessions.map(s => s.afi))).sort().map(value => ({ value, label: value })) } : field), [sessions])
const q = search.toLowerCase()
const filtered = useMemo(() => {
if (urlError) return []
const base = sessions.filter((s) => {
if (stateFilter !== "all" && s.state !== stateFilter) return false
if (typeFilter !== "all" && s.type !== typeFilter) return false
if (
q &&
!s.peerIp.includes(q) &&
!s.description.toLowerCase().includes(q) &&
!s.serverLabel.toLowerCase().includes(q) &&
!String(s.remoteAs).includes(q) &&
!(s.remoteAs === null ? "" : AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
) {
return false
}
return true
})
return applyFilterQuery(base, advancedFilters, BGP_FILTER_ACCESSORS)
}, [sessions, q, stateFilter, typeFilter, advancedFilters, urlError])
return (
<div className="flex flex-col gap-4">
{urlError && <Alert variant="warning"><AlertCircleIcon /><AlertDescription>{urlError}<Button variant="outline" size="sm" onClick={reset}>Сбросить фильтры BGP</Button></AlertDescription></Alert>}
<DataPageCard>
<DataPageToolbar
className="[&>div:first-child]:max-w-full [&>div:first-child]:overflow-x-auto [&>div:first-child_button]:shrink-0 [&>div:first-child_button]:whitespace-nowrap"
segmented={{
value: stateFilter,
onChange: setStateFilter,
options: [
{ value: "all", label: "Все", count: sessions.length },
{ value: "Unknown", label: "Неизвестно", count: sessions.filter(s => s.state === "Unknown").length },
{ value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length },
{ value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length },
{ value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
{ value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
],
}}
filterControl={<QueryFilters fields={fields} query={advancedFilters} onQueryChange={setAdvancedFilters} />}
search={search}
onSearchChange={setSearch}
searchPlaceholder="IP, AS, описание…"
countLabel={`${filtered.length} из ${sessions.length}`}
actions={
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{(["all", "eBGP", "iBGP", "Unknown"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTypeFilter(t)}
aria-pressed={typeFilter === t}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors",
typeFilter === t
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t === "all" ? "Все типы" : t === "Unknown" ? "Неизвестно" : t}
</button>
))}
</div>
}
/>
{selectedSessionId && sessions.some(s => s.id === selectedSessionId) && !filtered.some(s => s.id === selectedSessionId) && <p role="status" className="px-5 py-2 text-xs text-muted-foreground">Сессия из ссылки скрыта текущими фильтрами.</p>}
{hasFilters && !urlError && <Button variant="ghost" size="sm" onClick={reset}>Сбросить фильтры BGP</Button>}
<BgpSessionsDataGrid sessions={filtered} emptyTitle={urlError ? "Некорректные фильтры BGP" : emptyTitle} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />
</DataPageCard>
</div>
)
}
// ─── routers tab ──────────────────────────────────────────────────────────────
function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
const byRouter = useMemo(() => {
const map: Record<string, { label: string; site: string; sessions: BgpSession[] }> = {}
sessions.forEach(s => {
if (!map[s.serverId]) map[s.serverId] = { label: s.serverLabel, site: s.serverSite, sessions: [] }
map[s.serverId].sessions.push(s)
})
return Object.entries(map).map(([id, v]) => ({ id, ...v }))
}, [sessions])
return (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{byRouter.map(router => {
const estCnt = router.sessions.filter(s => s.state === "Established").length
const downCnt = router.sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length
const totalRx = sumBgpCounters(router.sessions.map(s => s.prefixesRx))
return (
<Frame key={router.id} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
{/* header */}
<div className="flex items-center gap-3 px-4 py-3 border-b">
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-none">{router.label}</p>
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · {Array.from(new Set(router.sessions.map(s => s.localAs === null ? "AS неизвестна" : "AS" + s.localAs))).join(", ")}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{estCnt > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
✓ {estCnt}
</span>
)}
{downCnt > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
style={{ background: "var(--status-degraded-bg)", color: "var(--status-degraded-fg)" }}>
⚠ {downCnt}
</span>
)}
</div>
</div>
{/* session rows */}
<div className="divide-y divide-border/60">
{router.sessions.map(s => {
const ss = STATE_STYLE[s.state]
return (
<div key={s.id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/30 transition-colors">
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-mono">{s.peerIp}</span>
<TypeBadge type={s.type} />
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-muted-foreground">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
{s.remoteAs !== null && AS_NAMES[s.remoteAs] && (
<span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span>
)}
</div>
</div>
<div className="shrink-0 text-right">
{s.prefixesRx !== null && s.prefixesRx > 0 && (
<p className="text-[10px] font-mono text-emerald-500 tabular-nums">
↓ {fmtNum(s.prefixesRx)}
</p>
)}
<p className={cn("text-[10px] font-semibold", ss.text)}>{ss.label}</p>
</div>
</div>
)
})}
</div>
{/* footer summary */}
{totalRx !== null && totalRx > 0 && (
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20">
<ArrowDownIcon className="size-3 text-emerald-500" />
<span className="text-[11px] font-mono text-muted-foreground">
Всего получено: <span className="text-emerald-600 dark:text-emerald-400 font-semibold">{fmtNum(totalRx)}</span> префиксов
</span>
</div>
)}
</FramePanel>
</Frame>
)
})}
</div>
)
}
// ─── analytics tab ────────────────────────────────────────────────────────────
function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
// top peers by prefixes received (eBGP only)
const topPeers = useMemo(() =>
(sessions.some(s => s.prefixesRx === null) ? [] : [...sessions])
.filter(s => s.prefixesRx !== null && s.prefixesRx > 0)
.sort((a, b) => (b.prefixesRx ?? 0) - (a.prefixesRx ?? 0))
.slice(0, 8),
[sessions]
)
const maxRx = topPeers[0]?.prefixesRx ?? 1
// state distribution
const stateCounts = useMemo(() => {
const map: Record<string, number> = {}
sessions.forEach(s => { map[s.state] = (map[s.state] ?? 0) + 1 })
return Object.entries(map).sort((a, b) => b[1] - a[1]) as [BgpState, number][]
}, [sessions])
// total prefix stats
const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx))
const totalActive = sumBgpCounters(sessions.map(s => s.prefixesActive))
const ebgpSessions = sessions.filter(s => s.type === "eBGP").length
const ibgpSessions = sessions.filter(s => s.type === "iBGP").length
return (
<div className="flex flex-col gap-5">
<KpiStatGrid
aria-label="Сводка префиксов BGP"
items={[
{
id: "rx",
label: "Префиксы",
value: fmtNum(totalRx),
icon: <DownloadIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "active",
label: "Маршруты",
hint: totalActive === null ? "нет данных API" : undefined,
value: fmtNum(totalActive),
icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "ebgp",
label: "eBGP",
value: sessions.some(s => s.type === "Unknown") ? "—" : ebgpSessions,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "ibgp",
label: "iBGP",
value: sessions.some(s => s.type === "Unknown") ? "—" : ibgpSessions,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
]}
/>
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
{/* prefixes by peer — horizontal bar chart */}
<OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
<div className="flex flex-col gap-3">
{topPeers.map((s, i) => {
const pct = ((s.prefixesRx ?? 0) / maxRx) * 100
const ss = STATE_STYLE[s.state]
return (
<div key={s.id} className="flex items-center gap-3">
<span className="text-[11px] font-mono text-muted-foreground w-4 tabular-nums text-right">
{i + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1 gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-mono truncate">{s.peerIp}</span>
<span className="text-[10px] text-muted-foreground shrink-0">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
<TypeBadge type={s.type} />
</div>
<span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}>
{fmtNum(s.prefixesRx)}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", ss.dot)}
style={{ width: `${pct}%`, opacity: s.state === "Established" ? 0.8 : 0.3 }}
/>
</div>
<p className="text-[10px] text-muted-foreground mt-0.5">{s.serverLabel}</p>
</div>
</div>
)
})}
{topPeers.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">Недостаточно данных о префиксах для сравнения</p>
)}
</div>
</OpsPanel>
{/* right column */}
<div className="flex flex-col gap-4">
{/* state distribution */}
<OpsPanel title="Распределение состояний" contentClassName="pt-4 pb-4 px-5">
{stateCounts.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет данных</p>
) : (
<div className="flex flex-col gap-2">
{stateCounts.map(([state, count]) => {
const ss = STATE_STYLE[state]
const pct = sessions.length > 0 ? (count / sessions.length) * 100 : 0
return (
<div key={state} className="flex items-center gap-2">
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
<span className="text-xs w-24">{ss.label}</span>
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} />
</div>
<span className="text-xs font-mono tabular-nums w-6 text-right">{count}</span>
</div>
)
})}
</div>
)}
</OpsPanel>
{/* eBGP vs iBGP */}
<OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5">
{sessions.length === 0 || sessions.some(s => s.type === "Unknown") ? (
<p className="text-xs text-muted-foreground">Нет данных</p>
) : (
<>
{[
{ type: "eBGP" as BgpType, label: "Внешние (eBGP)", count: ebgpSessions },
{ type: "iBGP" as BgpType, label: "Внутренние (iBGP)", count: ibgpSessions },
].map(({ type, count }) => {
const pct = sessions.length > 0 ? (count / sessions.length) * 100 : 0
return (
<div key={type} className="flex items-center gap-2 mb-2 last:mb-0">
<TypeBadge type={type} />
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", type === "eBGP" ? "bg-blue-500" : "bg-purple-500")}
style={{ width: `${pct}%`, opacity: 0.75 }} />
</div>
<span className="text-xs font-mono w-4 text-right">{count}</span>
</div>
)
})}
{/* active AS list */}
<div className="mt-4 pt-3 border-t border-border/60">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-2 font-semibold">Автономные системы</p>
<div className="flex flex-col gap-1">
{Object.entries(
sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((acc, s) => {
if (s.remoteAs !== null) acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc
}, {})
).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => (
<div key={as} className="flex items-center justify-between text-[11px]">
<span className="font-mono text-muted-foreground">AS{as}</span>
<span className="text-muted-foreground/60">{AS_NAMES[Number(as)] ?? ""}</span>
<span className="font-mono tabular-nums">{cnt}</span>
</div>
))}
</div>
</div>
</>
)}
</OpsPanel>
</div>
</div>
</div>
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
{ id: "sessions", label: "Сессии", icon: <ActivityIcon className="size-3.5" /> },
{ id: "routers", label: "По роутерам", icon: <ServerIcon className="size-3.5" /> },
{ id: "analytics", label: "Аналитика", icon: <BarChart3Icon className="size-3.5" /> },
]
export default function BgpPage() {
return <Suspense fallback={<div role="status">Загрузка BGP…</div>}><BgpRoute /></Suspense>
}
function BgpRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams()
const router = useRouter()
const selectedServerId = params.get("server") ?? ALL_SERVERS_ID
const selectedSessionId = params.get("session")
const tab = params.get("tab")
const activeTab: BgpTab = tab === "routers" || tab === "analytics" ? tab : "sessions"
function navigate(values: Record<string, string | null>) {
const next = new URLSearchParams(params.toString())
for (const [key, value] of Object.entries(values)) {
if (value === null) next.delete(key)
else next.set(key, value)
}
router.push("/bgp" + (next.size ? "?" + next.toString() : ""))
}
return <BgpPageContent key={JSON.stringify([mode, backendUrl])} selectedServerId={selectedServerId} selectedSessionId={selectedSessionId} activeTab={activeTab}
onSelectServer={id => navigate({ server: id === ALL_SERVERS_ID ? null : id, session: null })}
onSelectSession={id => navigate({ session: id })}
onSelectTab={tab => navigate({ tab: tab === "sessions" ? null : tab })} />
}
function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSelectServer, onSelectSession, onSelectTab }: {
selectedServerId: string; selectedSessionId: string | null; activeTab: BgpTab;
onSelectServer: (id: string) => void; onSelectSession: (id: string | null) => void; onSelectTab: (tab: BgpTab) => void;
}) {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
const [loading, setLoading] = useState(isLive)
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const [failures, setFailures] = useState<BgpFailure[]>([])
const [hasLoaded, setHasLoaded] = useState(false)
const [readReceipts, setReadReceipts] = useState<Record<string, string>>({})
const [fetchTick, setFetchTick] = useState(0)
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setLiveSessions([])
setLiveServers([])
setLiveError(null)
})
return
}
let cancelled = false
queueMicrotask(() => {
if (cancelled) return
setLoading(true)
void Promise.all([
requestJson<unknown>(backendUrl, "/api/bgp/catalog"),
requestJson<unknown>(backendUrl, "/api/servers"),
])
.then(([data, servers]) => {
if (cancelled) return
const catalog = bgpCatalogSchema.parse(data)
const inventory = bgpServersSchema.parse(servers)
const enabled = new Set(inventory.filter(s => s.enabled).map(s => s.id))
if (catalog.sessions.some(s => !enabled.has(s.serverId)) || catalog.failures.some(f => !enabled.has(f.serverId))) throw new Error("Чужой контекст BGP-каталога")
const failed = new Set(catalog.failures.map(f => String(f.serverId)))
setLiveSessions(previous => [...catalog.sessions.map(backendToFrontend), ...previous.filter(s => enabled.has(Number(s.serverId)) && failed.has(s.serverId))])
setLiveServers(inventory.filter(s => s.enabled).map(mapBackendServer))
setFailures(catalog.failures)
setLiveError(null)
setHasLoaded(true)
const receivedAt = new Date()
setFetchedAt(receivedAt)
setReadReceipts(previous => ({ ...Object.fromEntries(Object.entries(previous).filter(([id]) => enabled.has(Number(id)))), ...Object.fromEntries([...enabled].filter(id => !failed.has(String(id))).map(id => [String(id), receivedAt.toISOString()])) }))
setLoading(false)
})
.catch((err: unknown) => {
if (cancelled) return
setLiveError(err instanceof Error ? err.message : String(err))
setLoading(false)
})
})
return () => { cancelled = true }
}, [isLive, backendUrl, fetchTick])
const unknownIds = useMemo(() => new Set(isLive ? (liveError ? liveServers.map(s => s.id) : failures.map(f => String(f.serverId))) : []), [isLive, liveError, liveServers, failures])
const allSessions: BgpSession[] = isLive ? liveSessions.map(s => unknownIds.has(s.serverId) ? { ...s, state: "Unknown" } : s) : SESSIONS
const unavailableContext = isLive && hasLoaded && selectedServerId !== ALL_SERVERS_ID && !liveServers.some(s => s.id === selectedServerId)
const incomplete = isLive && (unavailableContext || !hasLoaded || Boolean(liveError) || (selectedServerId === ALL_SERVERS_ID ? failures.length > 0 : unknownIds.has(selectedServerId)))
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId = selectedServerId
const sessions = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return allSessions
return allSessions.filter((s) => s.serverId === effectiveServerId)
}, [allSessions, effectiveServerId])
const railItems = useMemo<ServerTileItem[]>(() => {
const counts = new Map<string, number>()
for (const s of allSessions) {
counts.set(s.serverId, (counts.get(s.serverId) ?? 0) + 1)
}
return displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: "monitoringUnknown" in s && s.monitoringUnknown ? undefined : s.status,
meta: "monitoringUnknown" in s && s.monitoringUnknown ? "Статус неизвестен" : undefined,
type: s.type,
count: isLive && (!hasLoaded || unknownIds.has(s.id)) ? undefined : counts.get(s.id) ?? 0,
enabled: s.enabled,
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
}))
}, [displayServers, allSessions, hasLoaded, isLive, unknownIds])
const established = sessions.filter(s => s.state === "Established").length
const notEstab = sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length
const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx))
return (
<ServerRailLayout
preserveSelection
items={railItems}
selectedId={effectiveServerId}
onSelect={onSelectServer}
showAll
allCount={displayServers.length}
loading={isLive && loading && displayServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
actions={
<>
<ServerRailMobileButton />
<Button variant="outline" size="sm" disabled={!isLive || loading} onClick={() => setFetchTick(t => t + 1)}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
</>
}
/>
}
banner={
<div className="border-b bg-background shrink-0">
<div className="flex min-w-0 items-center overflow-x-auto px-4 sm:px-6">
{TABS.map(t => (
<button key={t.id} type="button" aria-pressed={activeTab === t.id} onClick={() => onSelectTab(t.id)}
className={cn(
"flex shrink-0 items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{t.icon}{t.label}
</button>
))}
</div>
</div>
}
>
<div className="flex flex-col gap-5">
{unavailableContext && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Устройство BGP недоступно в этом каталоге</AlertTitle><AlertDescription>Контекст {selectedServerId} сохранён. Выберите доступное устройство.<Button variant="outline" size="sm" onClick={() => onSelectServer(ALL_SERVERS_ID)}>Все устройства BGP</Button></AlertDescription></Alert>}
{selectedSessionId && hasLoaded && !sessions.some(s => s.id === selectedSessionId) && <Alert variant="warning"><AlertCircleIcon /><AlertDescription>Сессия из ссылки отсутствует в выбранном снимке. Контекст ссылки сохранён.<Button variant="outline" size="sm" onClick={() => onSelectSession(null)}>Все сессии BGP</Button></AlertDescription></Alert>}
{/* data source banner */}
{isLive && loading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<RefreshCwIcon className="size-3.5 animate-spin" />
Загрузка BGP-сессий…
</div>
)}
{isLive && fetchedAt && (
<div role="group" aria-label="Свежесть BGP" className="text-xs text-muted-foreground">
Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>.
Это время получения браузером, не время измерения RouterOS.
{displayServers.filter(s => effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId).map(s => <p key={s.id}>{s.name}: {readReceipts[s.id] ? <time data-bgp-server={s.id} dateTime={readReceipts[s.id]}>{new Date(readReceipts[s.id]).toLocaleTimeString("ru")}</time> : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}</p>)}
</div>
)}
{isLive && (liveError || failures.length > 0) && (
<Alert variant="warning">
<AlertCircleIcon />
<AlertTitle>{liveError ? "Не удалось загрузить BGP" : "Не удалось опросить часть роутеров BGP"}</AlertTitle>
<AlertDescription>
{liveError ?? failures.map(f => f.serverName + ": " + f.error).join("; ")}. {hasLoaded ? "Показаны последние полученные данные; статус отказавших устройств неизвестен." : "Данные пока неизвестны."}
<Button variant="outline" size="sm" disabled={loading} onClick={() => setFetchTick(t => t + 1)} aria-label="Повторить загрузку BGP">Повторить</Button>
</AlertDescription>
</Alert>
)}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
</span>
)}
<KpiStatGrid
aria-label="Сводка BGP"
items={[
{
id: "sessions",
label: "Сессий",
value: incomplete ? "—" : sessions.length,
hint: incomplete ? "неизвестно" : undefined,
icon: <GitMergeIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "established",
label: "Established",
value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : established,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "not-estab",
label: "Не поднято",
value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : notEstab,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
variant: notEstab > 0 ? "warning" : "default",
},
{
id: "prefixes",
label: "Префиксы",
value: incomplete ? "—" : fmtNum(totalRx),
icon: <DownloadIcon className="size-4" />,
iconClassName: "text-info",
},
]}
/>
{/* alert: not-established sessions */}
{!incomplete && notEstab > 0 && (
<Alert variant="warning">
<AlertCircleIcon />
<AlertTitle>
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
</AlertTitle>
<AlertDescription>
Проверьте {sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").map(s => s.peerIp).join(", ")}
</AlertDescription>
</Alert>
)}
{/* tab content */}
{activeTab === "sessions" && <SessionsTab sessions={sessions} emptyTitle={incomplete && sessions.length === 0 ? "Данные BGP неизвестны" : undefined} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />}
{activeTab === "routers" && (incomplete ? <p role="status">Сводка по роутерам неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <RoutersTab sessions={sessions} />)}
{activeTab === "analytics" && (incomplete ? <p role="status">Аналитика неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <AnalyticsTab sessions={sessions} />)}
</div>
</ServerRailLayout>
)
}