Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
773 lines
35 KiB
TypeScript
773 lines
35 KiB
TypeScript
"use client"
|
||
|
||
import { Fragment, 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 { Card, CardContent } from "@/components/ui/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,
|
||
} from "lucide-react"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
|
||
// ─── 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
|
||
}
|
||
|
||
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">
|
||
<Card className="overflow-hidden">
|
||
<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} />
|
||
</Card>
|
||
</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 (
|
||
<Card key={router.id} className="overflow-hidden gap-0 py-0">
|
||
{/* 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>
|
||
)}
|
||
</Card>
|
||
)
|
||
})}
|
||
</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">
|
||
{/* summary row */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
{[
|
||
{ label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" },
|
||
{ label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" },
|
||
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
|
||
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
|
||
].map(s => (
|
||
<Card key={s.label}>
|
||
<CardContent className="pt-4 pb-3 px-4">
|
||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
|
||
{/* prefixes by peer — horizontal bar chart */}
|
||
<Card>
|
||
<CardContent className="pt-4 pb-4 px-5">
|
||
<p className="text-sm font-semibold mb-4">Топ-8 пиров по полученным префиксам</p>
|
||
<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>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* right column */}
|
||
<div className="flex flex-col gap-4">
|
||
{/* state distribution */}
|
||
<Card>
|
||
<CardContent className="pt-4 pb-4 px-5">
|
||
<p className="text-sm font-semibold mb-3">Распределение состояний</p>
|
||
{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>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* eBGP vs iBGP */}
|
||
<Card>
|
||
<CardContent className="pt-4 pb-4 px-5">
|
||
<p className="text-sm font-semibold mb-3">eBGP vs iBGP</p>
|
||
{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>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</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 { mode, backendUrl } = useDataSource()
|
||
const isLive = mode === "live"
|
||
|
||
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
|
||
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) return
|
||
let cancelled = false
|
||
queueMicrotask(() => {
|
||
if (cancelled) return
|
||
setLoading(true)
|
||
setLiveError(null)
|
||
fetch(`${backendUrl}/api/bgp/sessions`)
|
||
.then(r => {
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||
return r.json() as Promise<BackendBgpSession[]>
|
||
})
|
||
.then(data => {
|
||
if (cancelled) return
|
||
setLiveSessions(data.map(backendToFrontend))
|
||
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])
|
||
|
||
// Use live or mock data for all tabs and KPI
|
||
const sessions = isLive ? liveSessions : SESSIONS
|
||
|
||
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(liveSessions.map(s => s.serverId)).size,
|
||
[liveSessions],
|
||
)
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
|
||
actions={
|
||
<>
|
||
<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>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
{/* tab bar */}
|
||
<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-1 overflow-y-auto p-6">
|
||
<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 && liveSessions.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>
|
||
)}
|
||
{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>
|
||
)}
|
||
|
||
{/* KPI strip */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
{[
|
||
{ label: "Сессий всего", value: sessions.length, color: "" },
|
||
{ label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" },
|
||
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
|
||
].map(s => (
|
||
<Card key={s.label}>
|
||
<CardContent className="pt-4 pb-3 px-4">
|
||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* 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>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|