Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m40s
Docker images / frontend-image (push) Successful in 2m50s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Added support for fetching and displaying server data in BGP, VXLAN, and OSPF pages, improving the overall user experience. - Introduced new backend routes for OSPF and VXLAN, allowing for better data management and retrieval. - Implemented mapping functions for backend server data to frontend types, ensuring consistency across components. - Enhanced the sidebar to display counts for BGP sessions, VXLAN tunnels, and containers, providing users with quick insights into their network status. - Updated tests to cover new functionalities and ensure reliability. Co-authored-by: Cursor <[email protected]>
889 lines
39 KiB
TypeScript
889 lines
39 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useMemo, useEffect } from "react"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
|
||
import type { Filter } from "@/components/reui/filters"
|
||
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
|
||
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
|
||
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||
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 { Input } from "@/components/ui/input"
|
||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||
import { cn } from "@/lib/utils"
|
||
import {
|
||
RefreshCwIcon, DownloadIcon, SearchIcon,
|
||
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
||
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
||
XIcon, 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 }> = {
|
||
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 }> = {
|
||
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 StateBadge({ state }: { state: BgpState }) {
|
||
const s = STATE_STYLE[state]
|
||
return (
|
||
<span className={cn("inline-flex items-center gap-1.5 rounded border px-2 py-0.5 text-[11px] font-semibold",
|
||
s.bg, s.text, "border-current/20")}>
|
||
<span className={cn("size-1.5 rounded-full shrink-0", s.dot)} />
|
||
{s.label}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
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}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
|
||
function fmtNum(n: number) {
|
||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`
|
||
return String(n)
|
||
}
|
||
|
||
// ─── backend mapping ──────────────────────────────────────────────────────────
|
||
|
||
interface BackendBgpSession {
|
||
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
|
||
name: string; peerIp: string; remoteAs: number; localAs: number
|
||
localId: string; remoteId: string; state: string; type: "eBGP" | "iBGP"
|
||
uptime: string | null; holdTime: number; keepalive: number
|
||
prefixesRx: number; prefixesTx: number
|
||
inputMessages: number; outputMessages: number
|
||
capabilities: string[]; lastError: string | null
|
||
}
|
||
|
||
interface BackendServer {
|
||
id: number
|
||
name: string
|
||
host: string
|
||
type?: Server["type"]
|
||
site?: string
|
||
country: string
|
||
asn?: string
|
||
enabled: boolean
|
||
status?: Server["status"]
|
||
latency?: number | null
|
||
}
|
||
|
||
function mapBackendServer(s: BackendServer): Server {
|
||
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 ?? "online",
|
||
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: (b.state as BgpState) || "Idle",
|
||
type: b.type,
|
||
afi: "IPv4 Unicast",
|
||
uptime: b.uptime,
|
||
holdTime: b.holdTime,
|
||
keepalive: b.keepalive,
|
||
prefixesRx: b.prefixesRx,
|
||
prefixesTx: b.prefixesTx,
|
||
prefixesActive: b.prefixesRx,
|
||
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 }: { sessions: BgpSession[] }) {
|
||
const [search, setSearch] = useState("")
|
||
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
|
||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||
|
||
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) &&
|
||
!(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
|
||
) {
|
||
return false
|
||
}
|
||
return true
|
||
})
|
||
return applyReuiFilters(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 },
|
||
],
|
||
}}
|
||
filters={advancedFilters}
|
||
onFiltersChange={setAdvancedFilters}
|
||
filterFields={BGP_FILTER_FIELDS}
|
||
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} />
|
||
</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.length - estCnt
|
||
const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||
|
||
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} · AS65001</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">AS{s.remoteAs}</span>
|
||
{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 > 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)}>{s.state}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* footer summary */}
|
||
{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]
|
||
.filter(s => s.prefixesRx > 0)
|
||
.sort((a, b) => b.prefixesRx - a.prefixesRx)
|
||
.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 = sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||
const totalActive = sessions.reduce((a, s) => a + s.prefixesActive, 0)
|
||
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: "Активных маршрутов",
|
||
value: fmtNum(totalActive),
|
||
icon: <GitMergeIcon className="size-4" />,
|
||
iconClassName: "text-info",
|
||
},
|
||
{
|
||
id: "ebgp",
|
||
label: "eBGP сессий",
|
||
value: ebgpSessions,
|
||
icon: <ActivityIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "ibgp",
|
||
label: "iBGP сессий",
|
||
value: 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 / 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">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">{state}</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 ? (
|
||
<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) => {
|
||
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() {
|
||
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
|
||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||
|
||
const { mode, backendUrl } = useDataSource()
|
||
const isLive = mode === "live"
|
||
|
||
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
|
||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
|
||
const [liveError, setLiveError] = useState<string | null>(null)
|
||
const [fetchTick, setFetchTick] = useState(0)
|
||
|
||
useEffect(() => {
|
||
if (!isLive) {
|
||
queueMicrotask(() => {
|
||
setLiveSessions([])
|
||
setLiveServers([])
|
||
setLiveError(null)
|
||
})
|
||
return
|
||
}
|
||
let cancelled = false
|
||
queueMicrotask(() => {
|
||
if (cancelled) return
|
||
setLoading(true)
|
||
setLiveError(null)
|
||
void Promise.all([
|
||
requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions"),
|
||
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
||
])
|
||
.then(([data, servers]) => {
|
||
if (cancelled) return
|
||
setLiveSessions(data.map(backendToFrontend))
|
||
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
|
||
setFetchedAt(new Date())
|
||
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 allSessions = isLive ? liveSessions : SESSIONS
|
||
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||
|
||
const effectiveServerId =
|
||
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
||
? selectedServerId
|
||
: ALL_SERVERS_ID
|
||
|
||
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: s.status,
|
||
type: s.type,
|
||
count: counts.get(s.id) ?? 0,
|
||
enabled: s.enabled,
|
||
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
||
}))
|
||
}, [displayServers, allSessions])
|
||
|
||
const established = sessions.filter(s => s.state === "Established").length
|
||
const notEstab = sessions.length - established
|
||
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||
const serverCount = useMemo(
|
||
() => new Set(sessions.map(s => s.serverId)).size,
|
||
[sessions],
|
||
)
|
||
|
||
return (
|
||
<ServerRailLayout
|
||
items={railItems}
|
||
selectedId={effectiveServerId}
|
||
onSelect={setSelectedServerId}
|
||
showAll
|
||
allCount={displayServers.length}
|
||
loading={isLive && loading && displayServers.length === 0}
|
||
header={
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
|
||
actions={
|
||
<>
|
||
<ServerRailMobileButton />
|
||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||
Обновить
|
||
</Button>
|
||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||
</>
|
||
}
|
||
/>
|
||
}
|
||
banner={
|
||
<div className="border-b bg-background shrink-0">
|
||
<div className="flex items-center px-6">
|
||
{TABS.map(t => (
|
||
<button key={t.id} onClick={() => setActiveTab(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">
|
||
|
||
{/* 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 && !loading && fetchedAt && !liveError && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
||
<span className="size-1.5 rounded-full bg-emerald-500" />
|
||
Живые данные · {serverCount} серверов · обновлено {fetchedAt.toLocaleTimeString("ru")}
|
||
</span>
|
||
<button
|
||
onClick={() => setFetchTick(t => t + 1)}
|
||
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
|
||
<RefreshCwIcon className="size-3" />
|
||
Обновить
|
||
</button>
|
||
</div>
|
||
)}
|
||
{isLive && liveError && (
|
||
<Alert variant="warning" className="py-2">
|
||
<AlertCircleIcon />
|
||
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
{isLive && !loading && allSessions.length === 0 && !liveError && fetchedAt && (
|
||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||
BGP не настроен ни на одном сервере
|
||
</div>
|
||
)}
|
||
{isLive && !loading && allSessions.length > 0 && sessions.length === 0 && !liveError && (
|
||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||
На выбранном сервере нет BGP-сессий
|
||
</div>
|
||
)}
|
||
{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: sessions.length,
|
||
icon: <GitMergeIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "established",
|
||
label: "Established",
|
||
value: established,
|
||
icon: <CheckCircleIcon className="size-4" />,
|
||
iconClassName: "text-success",
|
||
},
|
||
{
|
||
id: "not-estab",
|
||
label: "Не установлено",
|
||
value: notEstab,
|
||
icon: <AlertCircleIcon className="size-4" />,
|
||
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
|
||
variant: notEstab > 0 ? "warning" : "default",
|
||
},
|
||
{
|
||
id: "prefixes",
|
||
label: "Получено префиксов",
|
||
value: fmtNum(totalRx),
|
||
icon: <DownloadIcon className="size-4" />,
|
||
iconClassName: "text-info",
|
||
},
|
||
]}
|
||
/>
|
||
|
||
{/* alert: not-established sessions */}
|
||
{notEstab > 0 && (
|
||
<Alert variant="warning">
|
||
<AlertCircleIcon />
|
||
<AlertTitle>
|
||
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
|
||
</AlertTitle>
|
||
<AlertDescription>
|
||
Проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")}
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* tab content */}
|
||
{activeTab === "sessions" && <SessionsTab sessions={sessions} />}
|
||
{activeTab === "routers" && <RoutersTab sessions={sessions} />}
|
||
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
|
||
|
||
</div>
|
||
</ServerRailLayout>
|
||
)
|
||
}
|