Init commit

This commit is contained in:
Denozordec
2026-05-02 01:17:08 +07:00
commit f3f831653f
104 changed files with 43827 additions and 0 deletions
+979
View File
@@ -0,0 +1,979 @@
"use client"
import { Fragment, useState, useMemo, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
RefreshCwIcon, DownloadIcon, SearchIcon,
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
XIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
// ─── types ────────────────────────────────────────────────────────────────────
type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm"
type BgpType = "eBGP" | "iBGP"
type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
type BgpTab = "sessions" | "routers" | "analytics"
type StateFilter = "all" | BgpState
type TypeFilter = "all" | BgpType
interface BgpSession {
id: string
serverId: string
serverLabel: string
serverSite: string
peerIp: string
remoteAs: number
localAs: number
routerId: string
description: string
state: BgpState
type: BgpType
afi: BgpAfi
uptime: string | null
holdTime: number
keepalive: number
prefixesRx: number
prefixesTx: number
prefixesActive: number
inputMessages: number
outputMessages: number
capabilities: string[]
lastError: string | null
}
// ─── AS name lookup ───────────────────────────────────────────────────────────
const AS_NAMES: Record<number, string> = {
8359: "МТС / Tele2",
13238: "Яндекс",
12389: "Ростелеком",
24940: "Hetzner",
6777: "AMS-IX",
1299: "Telia",
65001: "iBGP internal",
}
// ─── 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 CapChip({ cap }: { cap: string }) {
return (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border bg-muted/60 text-muted-foreground border-border/60">
{cap}
</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)
}
function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) {
const max = Math.max(rx, 1)
return (
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
{[
{ label: "Получено", val: rx, color: "bg-emerald-500", w: rx / max },
{ label: "Активных", val: active, color: "bg-sky-500", w: active / max },
{ label: "Отправлено", val: tx, color: "bg-blue-400", w: Math.min(tx / max, 1) },
].map(r => (
<div key={r.label} className="flex items-center gap-2">
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", r.color)}
style={{ width: `${Math.max(r.w * 100, r.val > 0 ? 2 : 0)}%` }} />
</div>
<span className="w-14 text-right tabular-nums">{fmtNum(r.val)}</span>
</div>
))}
</div>
)
}
// ─── RSC snippet ──────────────────────────────────────────────────────────────
function rscSnippet(s: BgpSession) {
return `/routing bgp connection\nadd name=peer-as${s.remoteAs} remote.address=${s.peerIp}/32 \\\n remote.as=${s.remoteAs} local.role=${s.type === "eBGP" ? "ebgp" : "ibgp"} \\\n output.filter-chain=export-filter input.filter=import-filter \\\n routing-table=main`
}
// ─── session expanded row ─────────────────────────────────────────────────────
function SessionDetail({ s }: { s: BgpSession }) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard.writeText(rscSnippet(s)).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 1800)
})
}
return (
<div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{[
{ label: "Router ID", value: s.routerId },
{ label: "Hold / KA", value: `${s.holdTime}s / ${s.keepalive}s` },
{ label: "AFI/SAFI", value: s.afi },
{ label: "Сообщения ↓/↑", value: `${fmtNum(s.inputMessages)} / ${fmtNum(s.outputMessages)}` },
].map(({ label, value }) => (
<div key={label}>
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
<p className="text-xs font-mono font-medium">{value}</p>
</div>
))}
</div>
{/* prefix bars */}
{s.state === "Established" && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-2 uppercase tracking-wider font-semibold">Префиксы</p>
<PrefixBar rx={s.prefixesRx} tx={s.prefixesTx} active={s.prefixesActive} />
</div>
)}
{/* capabilities */}
{s.capabilities.length > 0 && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">Capabilities</p>
<div className="flex flex-wrap gap-1.5">
{s.capabilities.map(c => <CapChip key={c} cap={c} />)}
</div>
</div>
)}
{/* last error */}
{s.lastError && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2">
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
<p className="text-xs font-mono text-red-500">{s.lastError}</p>
</div>
)}
{/* rsc export */}
<div className="mt-2">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">RouterOS Export</p>
<div className="rounded-md bg-[#0a0f1a] border border-white/8 px-3 py-2.5 flex items-start justify-between gap-3">
<pre className="text-[10px] font-mono text-[#94a3b8] leading-relaxed whitespace-pre-wrap flex-1 min-w-0">
{rscSnippet(s)}
</pre>
<button onClick={copy}
className={cn(
"shrink-0 flex items-center gap-1 text-[10px] px-2 py-1 rounded border transition-colors",
copied
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
: "border-white/10 text-white/40 hover:text-white/70 hover:border-white/20",
)}>
<ClipboardCopyIcon className="size-3" />
{copied ? "Скопировано" : "Копировать"}
</button>
</div>
</div>
</div>
)
}
// ─── 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 STATE_FILTERS: Array<{ value: StateFilter; label: string }> = [
{ value: "all", label: "Все" },
{ value: "Established", label: "Established" },
{ value: "Active", label: "Active" },
{ value: "Idle", label: "Idle" },
{ value: "OpenSent", label: "OpenSent" },
]
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
const [search, setSearch] = useState("")
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [expandedId, setExpandedId] = useState<string | null>(null)
const q = search.toLowerCase()
const filtered = useMemo(() => 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
}), [sessions, q, stateFilter, typeFilter])
return (
<div className="flex flex-col gap-4">
{/* filter bar */}
<div className="flex items-center gap-2 flex-wrap">
{/* search */}
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<input
value={search} onChange={e => setSearch(e.target.value)}
placeholder="IP, AS, описание…"
className="h-8 pl-8 pr-3 w-52 text-xs bg-muted/50 border border-border rounded-md outline-none focus:border-primary transition-colors"
/>
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3" />
</button>
)}
</div>
{/* state filter */}
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{STATE_FILTERS.map(f => (
<button key={f.value} onClick={() => setStateFilter(f.value)}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors whitespace-nowrap",
stateFilter === f.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{f.label}
</button>
))}
</div>
{/* type filter */}
<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} 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>
<span className="text-xs text-muted-foreground ml-auto">
{filtered.length} из {sessions.length}
</span>
</div>
{/* table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
<th className="w-8" />
{["Роутер", "Peer IP", "Remote AS", "Описание", "Тип", "Состояние", "Uptime", "Prefixes ↓", "Prefixes ↑"].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{filtered.map(s => {
const isOpen = expandedId === s.id
return (
<Fragment key={s.id}>
<tr
onClick={() => setExpandedId(isOpen ? null : s.id)}
className={cn(
"cursor-pointer transition-colors",
isOpen ? "bg-muted/30" : "hover:bg-muted/20",
)}>
<td className="pl-3 py-2.5">
{isOpen
? <ChevronDownIcon className="size-3.5 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 text-muted-foreground" />}
</td>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{s.serverLabel}</td>
<td className="px-3 py-2.5 font-mono">{s.peerIp}</td>
<td className="px-3 py-2.5 font-mono">
<div className="flex items-center gap-1.5">
<span>AS{s.remoteAs}</span>
{AS_NAMES[s.remoteAs] && (
<span className="text-muted-foreground text-[10px]">{AS_NAMES[s.remoteAs]}</span>
)}
</div>
</td>
<td className="px-3 py-2.5 text-muted-foreground max-w-[180px] truncate">{s.description}</td>
<td className="px-3 py-2.5"><TypeBadge type={s.type} /></td>
<td className="px-3 py-2.5"><StateBadge state={s.state} /></td>
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground">
{s.uptime ?? "—"}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
{s.prefixesRx > 0
? <span className="text-emerald-600 dark:text-emerald-400">{fmtNum(s.prefixesRx)}</span>
: <span className="text-muted-foreground"></span>}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
{s.prefixesTx > 0
? <span className="text-blue-500">{fmtNum(s.prefixesTx)}</span>
: <span className="text-muted-foreground"></span>}
</td>
</tr>
{isOpen && (
<tr>
<td colSpan={10} className="p-0">
<SessionDetail s={s} />
</td>
</tr>
)}
</Fragment>
)
})}
{filtered.length === 0 && (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-sm text-muted-foreground">
Нет сессий по заданным фильтрам
</td>
</tr>
)}
</tbody>
</table>
</div>
</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 bg-emerald-500/10 text-emerald-500 border border-emerald-500/25 font-medium">
{estCnt}
</span>
)}
{downCnt > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/25 font-medium">
{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, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
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) { setLiveSessions([]); return }
let cancelled = false
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 && (
<div className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/8 px-3 py-2">
<span className="size-1.5 rounded-full bg-amber-500 shrink-0" />
<p className="text-xs text-amber-600 dark:text-amber-400">Ошибка загрузки: {liveError}</p>
</div>
)}
{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 && (
<div className="flex items-center gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/8 px-4 py-2.5">
<span className="size-2 rounded-full bg-amber-500 shrink-0" />
<p className="text-sm text-amber-600 dark:text-amber-400">
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")}
</p>
</div>
)}
{/* tab content */}
{activeTab === "sessions" && <SessionsTab sessions={sessions} />}
{activeTab === "routers" && <RoutersTab sessions={sessions} />}
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
</div>
</div>
</div>
)
}