fix(ui): сохранять BGP-сессии при частичных отказах
This commit is contained in:
1 parent
85e701960e
commit
7046808429
8 files changed
+181
-119
No files matched your search
+67
-113
@@ -10,20 +10,20 @@ import type { FilterQuery } from "@/components/reui/filters/filters-types"
|
||||
import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
|
||||
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
|
||||
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||
import { bgpCatalogSchema, bgpServersSchema, type BackendBgpSession, type BgpFailure } from "@/lib/bgp/response"
|
||||
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
RefreshCwIcon, DownloadIcon, SearchIcon,
|
||||
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
||||
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
||||
XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
|
||||
RefreshCwIcon, DownloadIcon,
|
||||
ActivityIcon, BarChart3Icon,
|
||||
ArrowDownIcon, ServerIcon,
|
||||
AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
@@ -186,6 +186,7 @@ const SESSIONS: BgpSession[] = [
|
||||
// ─── visual helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
|
||||
Unknown: { bg: "bg-muted", text: "text-muted-foreground", dot: "bg-muted-foreground", label: "Неизвестно" },
|
||||
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" },
|
||||
@@ -198,17 +199,6 @@ const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
|
||||
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 (
|
||||
@@ -228,45 +218,12 @@ function fmtNum(n: number) {
|
||||
|
||||
// ─── 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
|
||||
}
|
||||
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
type?: Server["type"]
|
||||
site?: string
|
||||
country: string
|
||||
asn?: string
|
||||
enabled: boolean
|
||||
status?: Server["status"]
|
||||
latency?: number | null
|
||||
}
|
||||
|
||||
function mapBackendServer(s: BackendServer): Server {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
model: "—",
|
||||
os: "—",
|
||||
site: s.site ?? "",
|
||||
country: s.country || "UN",
|
||||
asn: s.asn ?? "",
|
||||
type: s.type ?? "exit-node",
|
||||
enabled: s.enabled,
|
||||
status: s.status ?? "online",
|
||||
latency: s.latency ?? null,
|
||||
sessions: 0,
|
||||
}
|
||||
type BackendServer = ReturnType<typeof bgpServersSchema.parse>[number]
|
||||
type InventoryServer = Server & { monitoringUnknown?: boolean }
|
||||
function mapBackendServer(s: BackendServer): InventoryServer {
|
||||
return { id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", site: s.site ?? "",
|
||||
country: s.country || "UN", asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled,
|
||||
status: s.status ?? "offline", monitoringUnknown: s.status == null, latency: s.latency ?? null, sessions: 0 }
|
||||
}
|
||||
|
||||
function backendToFrontend(b: BackendBgpSession): BgpSession {
|
||||
@@ -280,7 +237,7 @@ function backendToFrontend(b: BackendBgpSession): BgpSession {
|
||||
localAs: b.localAs,
|
||||
routerId: b.remoteId || b.localId,
|
||||
description: b.name,
|
||||
state: (b.state as BgpState) || "Idle",
|
||||
state: Object.hasOwn(STATE_STYLE, b.state) ? b.state as BgpState : "Unknown",
|
||||
type: b.type,
|
||||
afi: "IPv4 Unicast",
|
||||
uptime: b.uptime,
|
||||
@@ -304,7 +261,7 @@ const BGP_FILTER_ACCESSORS = {
|
||||
afi: (s: BgpSession) => s.afi,
|
||||
}
|
||||
|
||||
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
function SessionsTab({ sessions, emptyTitle }: { sessions: BgpSession[]; emptyTitle?: string }) {
|
||||
const [search, setSearch] = useState("")
|
||||
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||
@@ -370,7 +327,7 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<BgpSessionsDataGrid sessions={filtered} />
|
||||
<BgpSessionsDataGrid sessions={filtered} emptyTitle={emptyTitle} />
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
@@ -656,6 +613,11 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
|
||||
]
|
||||
|
||||
export default function BgpPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
return <BgpPageContent key={JSON.stringify([mode, backendUrl])} />
|
||||
}
|
||||
|
||||
function BgpPageContent() {
|
||||
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
|
||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||
|
||||
@@ -663,10 +625,12 @@ export default function BgpPage() {
|
||||
const isLive = mode === "live"
|
||||
|
||||
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
|
||||
const [loading, setLoading] = useState(isLive)
|
||||
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
|
||||
const [liveError, setLiveError] = useState<string | null>(null)
|
||||
const [failures, setFailures] = useState<BgpFailure[]>([])
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
const [fetchTick, setFetchTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -682,15 +646,22 @@ export default function BgpPage() {
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
void Promise.all([
|
||||
requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions"),
|
||||
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
||||
requestJson<unknown>(backendUrl, "/api/bgp/catalog"),
|
||||
requestJson<unknown>(backendUrl, "/api/servers"),
|
||||
])
|
||||
.then(([data, servers]) => {
|
||||
if (cancelled) return
|
||||
setLiveSessions(data.map(backendToFrontend))
|
||||
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
|
||||
const catalog = bgpCatalogSchema.parse(data)
|
||||
const inventory = bgpServersSchema.parse(servers)
|
||||
const enabled = new Set(inventory.filter(s => s.enabled).map(s => s.id))
|
||||
if (catalog.sessions.some(s => !enabled.has(s.serverId)) || catalog.failures.some(f => !enabled.has(f.serverId))) throw new Error("Чужой контекст BGP-каталога")
|
||||
const failed = new Set(catalog.failures.map(f => String(f.serverId)))
|
||||
setLiveSessions(previous => [...catalog.sessions.map(backendToFrontend), ...previous.filter(s => enabled.has(Number(s.serverId)) && failed.has(s.serverId))])
|
||||
setLiveServers(inventory.filter(s => s.enabled).map(mapBackendServer))
|
||||
setFailures(catalog.failures)
|
||||
setLiveError(null)
|
||||
setHasLoaded(true)
|
||||
setFetchedAt(new Date())
|
||||
setLoading(false)
|
||||
})
|
||||
@@ -703,13 +674,12 @@ export default function BgpPage() {
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, fetchTick])
|
||||
|
||||
const allSessions = isLive ? liveSessions : SESSIONS
|
||||
const unknownIds = useMemo(() => new Set(isLive ? (liveError ? liveServers.map(s => s.id) : failures.map(f => String(f.serverId))) : []), [isLive, liveError, liveServers, failures])
|
||||
const allSessions: BgpSession[] = isLive ? liveSessions.map(s => unknownIds.has(s.serverId) ? { ...s, state: "Unknown" } : s) : SESSIONS
|
||||
const incomplete = isLive && (!hasLoaded || Boolean(liveError) || (selectedServerId === ALL_SERVERS_ID ? failures.length > 0 : unknownIds.has(selectedServerId)))
|
||||
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||
|
||||
const effectiveServerId =
|
||||
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
||||
? selectedServerId
|
||||
: ALL_SERVERS_ID
|
||||
const effectiveServerId = selectedServerId
|
||||
|
||||
const sessions = useMemo(() => {
|
||||
if (effectiveServerId === ALL_SERVERS_ID) return allSessions
|
||||
@@ -727,21 +697,19 @@ export default function BgpPage() {
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
status: "monitoringUnknown" in s && s.monitoringUnknown ? undefined : s.status,
|
||||
meta: "monitoringUnknown" in s && s.monitoringUnknown ? "Статус неизвестен" : undefined,
|
||||
type: s.type,
|
||||
count: counts.get(s.id) ?? 0,
|
||||
count: isLive && (!hasLoaded || unknownIds.has(s.id)) ? undefined : counts.get(s.id) ?? 0,
|
||||
enabled: s.enabled,
|
||||
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
||||
}))
|
||||
}, [displayServers, allSessions])
|
||||
}, [displayServers, allSessions, hasLoaded, isLive, unknownIds])
|
||||
|
||||
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(sessions.map(s => s.serverId)).size,
|
||||
[sessions],
|
||||
)
|
||||
|
||||
|
||||
return (
|
||||
<ServerRailLayout
|
||||
@@ -757,11 +725,10 @@ export default function BgpPage() {
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||||
<Button variant="outline" size="sm" disabled={!isLive || loading} onClick={() => setFetchTick(t => t + 1)}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -770,7 +737,7 @@ export default function BgpPage() {
|
||||
<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)}
|
||||
<button key={t.id} aria-pressed={activeTab === 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
|
||||
@@ -793,36 +760,22 @@ export default function BgpPage() {
|
||||
Загрузка 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>
|
||||
{isLive && fetchedAt && (
|
||||
<div role="group" aria-label="Свежесть BGP" className="text-xs text-muted-foreground">
|
||||
Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>.
|
||||
Это время получения браузером, не время измерения RouterOS.
|
||||
</div>
|
||||
)}
|
||||
{isLive && liveError && (
|
||||
<Alert variant="warning" className="py-2">
|
||||
{isLive && (liveError || failures.length > 0) && (
|
||||
<Alert variant="warning">
|
||||
<AlertCircleIcon />
|
||||
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||||
<AlertTitle>{liveError ? "Не удалось загрузить BGP" : "Не удалось опросить часть роутеров BGP"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{liveError ?? failures.map(f => f.serverName + ": " + f.error).join("; ")}. {hasLoaded ? "Показаны последние полученные данные; статус отказавших устройств неизвестен." : "Данные пока неизвестны."}
|
||||
<Button variant="outline" size="sm" disabled={loading} onClick={() => setFetchTick(t => t + 1)} aria-label="Повторить загрузку BGP">Повторить</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{isLive && !loading && allSessions.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>
|
||||
)}
|
||||
{isLive && !loading && allSessions.length > 0 && sessions.length === 0 && !liveError && (
|
||||
<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">
|
||||
Моковые данные
|
||||
@@ -835,21 +788,22 @@ export default function BgpPage() {
|
||||
{
|
||||
id: "sessions",
|
||||
label: "Сессий всего",
|
||||
value: sessions.length,
|
||||
value: incomplete ? "—" : sessions.length,
|
||||
hint: incomplete ? "неизвестно" : undefined,
|
||||
icon: <GitMergeIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "established",
|
||||
label: "Established",
|
||||
value: established,
|
||||
value: incomplete ? "—" : established,
|
||||
icon: <CheckCircleIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "not-estab",
|
||||
label: "Не установлено",
|
||||
value: notEstab,
|
||||
value: incomplete ? "—" : notEstab,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: notEstab > 0 ? "warning" : "default",
|
||||
@@ -857,7 +811,7 @@ export default function BgpPage() {
|
||||
{
|
||||
id: "prefixes",
|
||||
label: "Получено префиксов",
|
||||
value: fmtNum(totalRx),
|
||||
value: incomplete ? "—" : fmtNum(totalRx),
|
||||
icon: <DownloadIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
@@ -865,7 +819,7 @@ export default function BgpPage() {
|
||||
/>
|
||||
|
||||
{/* alert: not-established sessions */}
|
||||
{notEstab > 0 && (
|
||||
{!incomplete && notEstab > 0 && (
|
||||
<Alert variant="warning">
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>
|
||||
@@ -878,9 +832,9 @@ export default function BgpPage() {
|
||||
)}
|
||||
|
||||
{/* tab content */}
|
||||
{activeTab === "sessions" && <SessionsTab sessions={sessions} />}
|
||||
{activeTab === "routers" && <RoutersTab sessions={sessions} />}
|
||||
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
|
||||
{activeTab === "sessions" && <SessionsTab sessions={sessions} emptyTitle={incomplete && sessions.length === 0 ? "Данные BGP неизвестны" : undefined} />}
|
||||
{activeTab === "routers" && (incomplete ? <p role="status">Сводка по роутерам неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <RoutersTab sessions={sessions} />)}
|
||||
{activeTab === "analytics" && (incomplete ? <p role="status">Аналитика неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <AnalyticsTab sessions={sessions} />)}
|
||||
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
Reference in new issue
Block a user