fix(ui): сохранять BGP-сессии при частичных отказах

This commit is contained in:
Denozordec committed 2026-09-30 16:48:46 +07:00
1 parent 85e701960e
commit 7046808429
8 files changed
+181 -119

No files matched your search

+67 -113
View File
@@ -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>
@@ -22,6 +22,7 @@ import { fmtBgpNum } from "@/lib/bgp/helpers"
import { ChevronDownIcon, ChevronRightIcon, NetworkIcon } from "lucide-react"
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",
@@ -100,9 +101,10 @@ function TypeBadge({ type }: { type: BgpType }) {
interface BgpSessionsDataGridProps {
sessions: BgpSessionRow[]
emptyTitle?: string
}
function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
function BgpSessionsDataGrid({ sessions, emptyTitle }: BgpSessionsDataGridProps) {
const columns = useMemo<ColumnDef<DataGridFeatures, BgpSessionRow>[]>(
() => [
{
@@ -272,7 +274,7 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
return (
<EmptyState
icon={<NetworkIcon className="size-4" />}
title="Нет BGP-сессий"
title={emptyTitle ?? "Нет BGP-сессий"}
description="Измените фильтры или проверьте подключение к роутерам"
className="border-0 py-10"
/>
+9 -1
View File
@@ -26,11 +26,19 @@
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.2, каталог BGP; далее экран |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.3, данные BGP; далее поля/контекст |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.3 — достоверность данных экрана BGP (2026-09-30)
- Экран использует /bgp/catalog и валидирует каталог/inventory, уникальные ID, failures и принадлежность включённым устройствам. Частичный отказ сохраняет строки отказавшего устройства, успешный пустой опрос удаляет их; общий сбой сохраняет последний снимок.
- Неизвестное начало не показывает пустой каталог/нули. Отказавшие строки имеют состояние «Неизвестно»; неполная сводка/аналитика не выдаёт старые значения за текущие. Исправный выбранный срез остаётся доступным. Null мониторинга не превращается в online. Смена mode/backendUrl перемонтирует источник; позднее чтение отклоняется cleanup.
- Постоянный alert с повтором, отметка получения браузером и блокировка повторного Refresh. Удалён пустой header Export; вкладки получили aria-pressed. Убран fallback выбранного устройства на все устройства.
- 12 E2E прошли: семь ошибок ответа/HTTP/inventory, частичный отказ и очистка, сортировка/раскрытие/Filters/пустые состояния BGP и OSPF. TypeScript/lint чистые.
- Далее — достоверность полей BGP (AFI, active prefixes, неизвестные RouterOS counters/state), URL/свежесть и приёмка источника/ПК/телефона. Эти поля ещё не исправлены; реальный RouterOS не проверен. Локальный коммит без push.
## Блок 10.2 — серверный каталог BGP (2026-09-30)
- Добавлен /bgp/catalog: sessions и failures по устройствам. Poller различает успешный пустой ответ и отказ, отклоняет чужой serverId, пустые/повторяющиеся ID. Старый /bgp/sessions сохраняет массив для оповещений; его неоднозначность остаётся до 11Б.
+4
View File
@@ -48,3 +48,7 @@
## Блок 10.2 — BGP API
Каталог sessions/failures и проверка контекста добавлены; старый агрегат совместим. Выборный отказ возвращает 503. Unit/Fastify/TypeScript/lint/backend build прошли. Следующий блок — потребитель нового каталога на экране BGP.
## Блок 10.3 — данные BGP
Экран мигрирован на каталог: валидация, кэш отказавшего устройства, неизвестные состояния, постоянный повтор и изоляция источника. 12 E2E/TypeScript/lint прошли. Далее — поля BGP, URL/свежесть и визуальная приёмка.
+22
View File
@@ -0,0 +1,22 @@
import { z } from "zod"
import { vxlanServersSchema } from "@/lib/vxlan-response"
const counter = z.number().int().nonnegative()
export const bgpSessionSchema = z.object({
id: z.string().trim().min(1), serverId: z.number().int().positive(),
serverName: z.string(), serverSite: z.string(), serverCountry: z.string(),
name: z.string(), peerIp: z.string(), remoteAs: counter, localAs: counter,
localId: z.string(), remoteId: z.string(), state: z.string().trim().min(1), type: z.enum(["eBGP", "iBGP"]),
uptime: z.string().nullable(), holdTime: counter, keepalive: counter,
prefixesRx: counter, prefixesTx: counter, inputMessages: counter, outputMessages: counter,
capabilities: z.array(z.string()), lastError: z.string().nullable(),
})
export const bgpCatalogSchema = z.object({
sessions: z.array(bgpSessionSchema),
failures: z.array(z.object({ serverId: z.number().int().positive(), serverName: z.string(), error: z.string() })),
}).refine(data => new Set(data.sessions.map(s => JSON.stringify([s.serverId, s.id]))).size === data.sessions.length
&& new Set(data.failures.map(f => f.serverId)).size === data.failures.length
&& !data.sessions.some(s => data.failures.some(f => f.serverId === s.serverId)))
export const bgpServersSchema = vxlanServersSchema
export type BackendBgpSession = z.infer<typeof bgpSessionSchema>
export type BgpFailure = z.infer<typeof bgpCatalogSchema>["failures"][number]
+1 -1
View File
@@ -1,4 +1,4 @@
export type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm"
export type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm" | "Unknown"
export type BgpType = "eBGP" | "iBGP"
export type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
+72
View File
@@ -0,0 +1,72 @@
import { test, expect } from "@playwright/test"
const server = (id: number) => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, country: "RU", status: null })
const session = (serverId: number, name = "Сессия " + serverId) => ({ id: "*1", serverId, serverName: "Роутер " + serverId, serverSite: "", serverCountry: "RU", name, peerIp: "192.0.2." + serverId, remoteAs: 64513, localAs: 64512, localId: "192.0.2.100", remoteId: "192.0.2." + serverId, state: "Established", type: "eBGP", uptime: "1h", holdTime: 90, keepalive: 30, prefixesRx: 12, prefixesTx: 2, inputMessages: 20, outputMessages: 10, capabilities: [], lastError: null })
const full = { sessions: [session(1), session(2)], failures: [] }
for (const broken of ["http", "payload", "duplicate", "conflict", "duplicate-failure", "foreign", "inventory"]) {
test('BGP: неизвестное начало, кэш и восстановление ' + broken, async ({ page }) => {
let phase = 0
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/servers") return r.fulfill({ json: phase === 2 && broken === "inventory" ? [server(1), server(1)] : [server(1), server(2)] })
if (path === "/api/bgp/catalog") {
if (phase === 0 || phase === 2 && broken === "http") return r.fulfill({ status: 503, json: { error: "BGP недоступен тест" } })
const failure = { serverId: 2, serverName: "Роутер 2", error: "Таймаут тест" }
const json = phase !== 2 ? full : broken === "payload" ? { sessions: [{}], failures: [] }
: broken === "duplicate" ? { sessions: [session(1), session(1)], failures: [] }
: broken === "conflict" ? { ...full, failures: [failure] }
: broken === "duplicate-failure" ? { sessions: [], failures: [failure, failure] }
: broken === "foreign" ? { sessions: [session(3)], failures: [] } : full
return r.fulfill({ json })
}
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/bgp")
const alert = page.getByRole("alert").filter({ hasText: "Не удалось загрузить BGP" })
await expect(alert).toContainText("Данные пока неизвестны")
await expect(page.getByText("Нет BGP-сессий", { exact: true })).toHaveCount(0)
await expect(page.getByLabel("Сводка BGP", { exact: true })).toContainText("неизвестно")
phase = 1
await alert.getByRole("button", { name: "Повторить загрузку BGP" }).click()
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(2)
await expect(page.getByRole("option").filter({ hasText: "Роутер 1" })).toContainText("Статус неизвестен")
phase = 2
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(alert).toContainText("Показаны последние полученные данные")
await expect(rows).toHaveCount(2)
await expect(rows.filter({ hasText: "Неизвестно" })).toHaveCount(2)
await page.getByRole("button", { name: "Аналитика", exact: true }).click()
await expect(page.getByRole("status").filter({ hasText: "Аналитика неизвестна" })).toBeVisible()
phase = 3
await alert.getByRole("button", { name: "Повторить загрузку BGP" }).click()
await expect(alert).toHaveCount(0)
await page.getByRole("button", { name: "Сессии", exact: true }).click()
await expect(rows.filter({ hasText: "Неизвестно" })).toHaveCount(0)
})
}
test("BGP: частичный отказ сохраняет устройство, пустой успешный опрос очищает", async ({ page }) => {
let phase = 0
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/servers" ? [server(1), server(2)] : path === "/api/bgp/catalog" ? phase === 0 ? full : { sessions: [session(1, "Обновлённая сессия")], failures: phase === 1 ? [{ serverId: 2, serverName: "Роутер 2", error: "Таймаут тест" }] : [] } : path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/bgp")
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(2)
phase = 1
await page.getByRole("button", { name: "Обновить", exact: true }).click()
const alert = page.getByRole("alert").filter({ hasText: "Не удалось опросить часть роутеров BGP" })
await expect(alert).toContainText("Роутер 2: Таймаут тест")
await expect(rows.filter({ hasText: "Сессия 2" })).toContainText("Неизвестно")
await expect(rows.filter({ hasText: "Обновлённая сессия" })).toContainText("Established")
await page.getByRole("option").filter({ hasText: "Роутер 1" }).click()
await expect(page.getByLabel("Сводка BGP", { exact: true })).not.toContainText("неизвестно")
await page.getByRole("option").filter({ hasText: "Роутер 2" }).click()
await expect(page.getByLabel("Сводка BGP", { exact: true })).toContainText("неизвестно")
phase = 2
await alert.getByRole("button", { name: "Повторить загрузку BGP" }).click()
await expect(rows).toHaveCount(0)
await expect(page.getByText("Нет BGP-сессий", { exact: true })).toBeVisible()
})
+2 -2
View File
@@ -23,9 +23,9 @@ async function setup(page: Page, path: string, empty = false) {
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
const json = path === "/api/ospf/all" ? (empty ? { instances: [], interfaces: [], neighbors: [], routes: [], bfdSessions: [] } : ospf)
: path === "/api/bgp/sessions" ? (empty ? [] : bgp)
: path === "/api/bgp/catalog" ? { sessions: empty ? [] : bgp, failures: [] }
: path === "/api/auth/config" ? { required: false }
: path === "/api/servers" ? [] : {}
: path === "/api/servers" ? [{ id: 1, name: "Тестовый роутер", host: "192.0.2.100", country: "RU", enabled: true, status: null }] : {}
return route.fulfill({ json })
})
await page.goto(path)