Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
801 lines
36 KiB
TypeScript
801 lines
36 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 { 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"
|
||
|
||
// ─── 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">
|
||
<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 { 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)
|
||
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
|
||
.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>
|
||
)}
|
||
|
||
<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>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|