870 lines
44 KiB
TypeScript
870 lines
44 KiB
TypeScript
"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 { createFilterQuery } from "@/components/reui/filters/filters-query"
|
||
import { QueryFilters } from "@/components/data-grids/query-filters"
|
||
import type { FilterQuery } from "@/components/reui/filters/filters-types"
|
||
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"
|
||
type StateFilter = "all" | BgpState
|
||
type TypeFilter = "all" | BgpType
|
||
|
||
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] = useState("")
|
||
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
|
||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||
const [advancedFilters, setAdvancedFilters] = useState<FilterQuery>(() => createFilterQuery())
|
||
|
||
const q = search.toLowerCase()
|
||
const filtered = useMemo(() => {
|
||
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.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])
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<DataPageCard>
|
||
<DataPageToolbar
|
||
segmented={{
|
||
value: stateFilter,
|
||
onChange: setStateFilter,
|
||
options: [
|
||
{ value: "all", label: "Все", count: sessions.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={BGP_FILTER_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"] as const).map((t) => (
|
||
<button
|
||
key={t}
|
||
type="button"
|
||
onClick={() => setTypeFilter(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}
|
||
</button>
|
||
))}
|
||
</div>
|
||
}
|
||
/>
|
||
<BgpSessionsDataGrid sessions={filtered} emptyTitle={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 items-center px-6">
|
||
{TABS.map(t => (
|
||
<button key={t.id} aria-pressed={activeTab === t.id} onClick={() => onSelectTab(t.id)}
|
||
className={cn(
|
||
"flex 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>
|
||
)
|
||
}
|