Files
MikrotikManager/app/(main)/bgp/page.tsx
T
DenozordecandCursor 5f29cfaf0f
Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped
fix(ui): выровнять chrome Frame и убрать Card-оболочки
Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 15:36:56 +07:00

768 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { 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,
} 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">
<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">
{/* 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 => (
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
</FramePanel>
</Frame>
))}
</div>
<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)
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 => (
<Frame key={s.label} className="h-full">
<FramePanel className="flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
</FramePanel>
</Frame>
))}
</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>
)
}