Added the sonner library for toast notifications across various components, improving user feedback for actions such as saving settings, syncing rules, and handling errors. Updated the layout to include a Toaster component for consistent notification display. Refactored alert messages in the backups, gre, and filters pages to utilize the new notification system, enhancing overall user experience.
989 lines
45 KiB
TypeScript
989 lines
45 KiB
TypeScript
"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 { 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 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-[var(--chart-rx)]", w: rx / max },
|
||
{ label: "Активных", val: active, color: "bg-[var(--chart-1)]", w: active / max },
|
||
{ label: "Отправлено", val: tx, color: "bg-[var(--chart-tx)]", 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 z-10" />
|
||
<Input
|
||
value={search} onChange={e => setSearch(e.target.value)}
|
||
placeholder="IP, AS, описание…"
|
||
className="h-8 pl-8 pr-8 w-52 text-xs"
|
||
/>
|
||
{search && (
|
||
<button onClick={() => setSearch("")}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground z-10">
|
||
<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-[var(--chart-tx)]">{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 border border-current/25 font-medium"
|
||
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
|
||
✓ {estCnt}
|
||
</span>
|
||
)}
|
||
{downCnt > 0 && (
|
||
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
|
||
style={{ background: "var(--status-degraded-bg)", color: "var(--status-degraded-fg)" }}>
|
||
⚠ {downCnt}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* session rows */}
|
||
<div className="divide-y divide-border/60">
|
||
{router.sessions.map(s => {
|
||
const ss = STATE_STYLE[s.state]
|
||
return (
|
||
<div key={s.id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/30 transition-colors">
|
||
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-mono">{s.peerIp}</span>
|
||
<TypeBadge type={s.type} />
|
||
</div>
|
||
<div className="flex items-center gap-1.5 mt-0.5">
|
||
<span className="text-[10px] text-muted-foreground">AS{s.remoteAs}</span>
|
||
{AS_NAMES[s.remoteAs] && (
|
||
<span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0 text-right">
|
||
{s.prefixesRx > 0 && (
|
||
<p className="text-[10px] font-mono text-emerald-500 tabular-nums">
|
||
↓ {fmtNum(s.prefixesRx)}
|
||
</p>
|
||
)}
|
||
<p className={cn("text-[10px] font-semibold", ss.text)}>{s.state}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* footer summary */}
|
||
{totalRx > 0 && (
|
||
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20">
|
||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||
<span className="text-[11px] font-mono text-muted-foreground">
|
||
Всего получено: <span className="text-emerald-600 dark:text-emerald-400 font-semibold">{fmtNum(totalRx)}</span> префиксов
|
||
</span>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── analytics tab ────────────────────────────────────────────────────────────
|
||
|
||
function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||
// top peers by prefixes received (eBGP only)
|
||
const topPeers = useMemo(() =>
|
||
[...sessions]
|
||
.filter(s => s.prefixesRx > 0)
|
||
.sort((a, b) => b.prefixesRx - a.prefixesRx)
|
||
.slice(0, 8),
|
||
[sessions]
|
||
)
|
||
const maxRx = topPeers[0]?.prefixesRx ?? 1
|
||
|
||
// state distribution
|
||
const stateCounts = useMemo(() => {
|
||
const map: Record<string, number> = {}
|
||
sessions.forEach(s => { map[s.state] = (map[s.state] ?? 0) + 1 })
|
||
return Object.entries(map).sort((a, b) => b[1] - a[1]) as [BgpState, number][]
|
||
}, [sessions])
|
||
|
||
// total prefix stats
|
||
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||
const totalActive = sessions.reduce((a, s) => a + s.prefixesActive, 0)
|
||
const ebgpSessions = sessions.filter(s => s.type === "eBGP").length
|
||
const ibgpSessions = sessions.filter(s => s.type === "iBGP").length
|
||
|
||
return (
|
||
<div className="flex flex-col gap-5">
|
||
{/* summary row */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
{[
|
||
{ label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" },
|
||
{ label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" },
|
||
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
|
||
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
|
||
].map(s => (
|
||
<Card key={s.label}>
|
||
<CardContent className="pt-4 pb-3 px-4">
|
||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
|
||
{/* prefixes by peer — horizontal bar chart */}
|
||
<Card>
|
||
<CardContent className="pt-4 pb-4 px-5">
|
||
<p className="text-sm font-semibold mb-4">Топ-8 пиров по полученным префиксам</p>
|
||
<div className="flex flex-col gap-3">
|
||
{topPeers.map((s, i) => {
|
||
const pct = (s.prefixesRx / maxRx) * 100
|
||
const ss = STATE_STYLE[s.state]
|
||
return (
|
||
<div key={s.id} className="flex items-center gap-3">
|
||
<span className="text-[11px] font-mono text-muted-foreground w-4 tabular-nums text-right">
|
||
{i + 1}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center justify-between mb-1 gap-2">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span className="text-xs font-mono truncate">{s.peerIp}</span>
|
||
<span className="text-[10px] text-muted-foreground shrink-0">AS{s.remoteAs}</span>
|
||
<TypeBadge type={s.type} />
|
||
</div>
|
||
<span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}>
|
||
{fmtNum(s.prefixesRx)}
|
||
</span>
|
||
</div>
|
||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||
<div
|
||
className={cn("h-full rounded-full transition-all", ss.dot)}
|
||
style={{ width: `${pct}%`, opacity: s.state === "Established" ? 0.8 : 0.3 }}
|
||
/>
|
||
</div>
|
||
<p className="text-[10px] text-muted-foreground mt-0.5">{s.serverLabel}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
{topPeers.length === 0 && (
|
||
<p className="text-sm text-muted-foreground text-center py-4">Нет данных о префиксах</p>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* right column */}
|
||
<div className="flex flex-col gap-4">
|
||
{/* state distribution */}
|
||
<Card>
|
||
<CardContent className="pt-4 pb-4 px-5">
|
||
<p className="text-sm font-semibold mb-3">Распределение состояний</p>
|
||
{stateCounts.length === 0 ? (
|
||
<p className="text-xs text-muted-foreground">Нет данных</p>
|
||
) : (
|
||
<div className="flex flex-col gap-2">
|
||
{stateCounts.map(([state, count]) => {
|
||
const ss = STATE_STYLE[state]
|
||
const pct = sessions.length > 0 ? (count / sessions.length) * 100 : 0
|
||
return (
|
||
<div key={state} className="flex items-center gap-2">
|
||
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
|
||
<span className="text-xs w-24">{state}</span>
|
||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||
<div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} />
|
||
</div>
|
||
<span className="text-xs font-mono tabular-nums w-6 text-right">{count}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* eBGP vs iBGP */}
|
||
<Card>
|
||
<CardContent className="pt-4 pb-4 px-5">
|
||
<p className="text-sm font-semibold mb-3">eBGP vs iBGP</p>
|
||
{sessions.length === 0 ? (
|
||
<p className="text-xs text-muted-foreground">Нет данных</p>
|
||
) : (
|
||
<>
|
||
{[
|
||
{ type: "eBGP" as BgpType, label: "Внешние (eBGP)", count: ebgpSessions },
|
||
{ type: "iBGP" as BgpType, label: "Внутренние (iBGP)", count: ibgpSessions },
|
||
].map(({ type, count }) => {
|
||
const pct = sessions.length > 0 ? (count / sessions.length) * 100 : 0
|
||
return (
|
||
<div key={type} className="flex items-center gap-2 mb-2 last:mb-0">
|
||
<TypeBadge type={type} />
|
||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||
<div className={cn("h-full rounded-full", type === "eBGP" ? "bg-blue-500" : "bg-purple-500")}
|
||
style={{ width: `${pct}%`, opacity: 0.75 }} />
|
||
</div>
|
||
<span className="text-xs font-mono w-4 text-right">{count}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
{/* active AS list */}
|
||
<div className="mt-4 pt-3 border-t border-border/60">
|
||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-2 font-semibold">Автономные системы</p>
|
||
<div className="flex flex-col gap-1">
|
||
{Object.entries(
|
||
sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((acc, s) => {
|
||
acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc
|
||
}, {})
|
||
).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => (
|
||
<div key={as} className="flex items-center justify-between text-[11px]">
|
||
<span className="font-mono text-muted-foreground">AS{as}</span>
|
||
<span className="text-muted-foreground/60">{AS_NAMES[Number(as)] ?? ""}</span>
|
||
<span className="font-mono tabular-nums">{cnt}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||
|
||
const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
|
||
{ id: "sessions", label: "Сессии", icon: <ActivityIcon className="size-3.5" /> },
|
||
{ id: "routers", label: "По роутерам", icon: <ServerIcon className="size-3.5" /> },
|
||
{ id: "analytics", label: "Аналитика", icon: <BarChart3Icon className="size-3.5" /> },
|
||
]
|
||
|
||
export default function BgpPage() {
|
||
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
|
||
|
||
const { mode, backendUrl, 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) return
|
||
let cancelled = false
|
||
queueMicrotask(() => {
|
||
if (cancelled) return
|
||
setLoading(true)
|
||
setLiveError(null)
|
||
fetch(`${backendUrl}/api/bgp/sessions`)
|
||
.then(r => {
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||
return r.json() as Promise<BackendBgpSession[]>
|
||
})
|
||
.then(data => {
|
||
if (cancelled) return
|
||
setLiveSessions(data.map(backendToFrontend))
|
||
setFetchedAt(new Date())
|
||
setLoading(false)
|
||
})
|
||
.catch((err: unknown) => {
|
||
if (cancelled) return
|
||
setLiveError(err instanceof Error ? err.message : String(err))
|
||
setLoading(false)
|
||
})
|
||
})
|
||
return () => { cancelled = true }
|
||
}, [isLive, backendUrl, fetchTick])
|
||
|
||
// Use live or mock data for all tabs and KPI
|
||
const sessions = isLive ? liveSessions : SESSIONS
|
||
|
||
const established = sessions.filter(s => s.state === "Established").length
|
||
const notEstab = sessions.length - established
|
||
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||
const serverCount = useMemo(
|
||
() => new Set(liveSessions.map(s => s.serverId)).size,
|
||
[liveSessions],
|
||
)
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
|
||
actions={
|
||
<>
|
||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||
Обновить
|
||
</Button>
|
||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
{/* tab bar */}
|
||
<div className="border-b bg-background shrink-0">
|
||
<div className="flex items-center px-6">
|
||
{TABS.map(t => (
|
||
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
||
className={cn(
|
||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||
activeTab === t.id
|
||
? "border-primary text-foreground"
|
||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||
)}>
|
||
{t.icon}{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<div className="flex flex-col gap-5">
|
||
|
||
{/* data source banner */}
|
||
{isLive && loading && (
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<RefreshCwIcon className="size-3.5 animate-spin" />
|
||
Загрузка BGP-сессий…
|
||
</div>
|
||
)}
|
||
{isLive && !loading && fetchedAt && !liveError && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
||
<span className="size-1.5 rounded-full bg-emerald-500" />
|
||
Живые данные · {serverCount} серверов · обновлено {fetchedAt.toLocaleTimeString("ru")}
|
||
</span>
|
||
<button
|
||
onClick={() => setFetchTick(t => t + 1)}
|
||
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
|
||
<RefreshCwIcon className="size-3" />
|
||
Обновить
|
||
</button>
|
||
</div>
|
||
)}
|
||
{isLive && liveError && (
|
||
<Alert variant="warning" className="py-2">
|
||
<AlertCircleIcon />
|
||
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
{isLive && !loading && liveSessions.length === 0 && !liveError && fetchedAt && (
|
||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||
BGP не настроен ни на одном сервере
|
||
</div>
|
||
)}
|
||
{mode === "mock" && (
|
||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||
Моковые данные
|
||
</span>
|
||
)}
|
||
|
||
{/* KPI strip */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
{[
|
||
{ label: "Сессий всего", value: sessions.length, color: "" },
|
||
{ label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" },
|
||
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
|
||
].map(s => (
|
||
<Card key={s.label}>
|
||
<CardContent className="pt-4 pb-3 px-4">
|
||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* alert: not-established sessions */}
|
||
{notEstab > 0 && (
|
||
<Alert variant="warning">
|
||
<AlertCircleIcon />
|
||
<AlertTitle>
|
||
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
|
||
</AlertTitle>
|
||
<AlertDescription>
|
||
Проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")}
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* tab content */}
|
||
{activeTab === "sessions" && <SessionsTab sessions={sessions} />}
|
||
{activeTab === "routers" && <RoutersTab sessions={sessions} />}
|
||
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|