From 70468084298acd88b9477de14e241dc5f84d361c Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 16:48:46 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20BGP-=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D1=87=D0=B0=D1=81=D1=82=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/bgp/page.tsx | 180 +++++++----------- .../data-grids/bgp-sessions-data-grid.tsx | 6 +- docs/ux-ui-progress.md | 10 +- docs/ux-ui-stage-10.md | 4 + lib/bgp/response.ts | 22 +++ lib/bgp/types.ts | 2 +- tests/e2e/bgp-data.spec.ts | 72 +++++++ tests/e2e/routing-tables.spec.ts | 4 +- 8 files changed, 181 insertions(+), 119 deletions(-) create mode 100644 lib/bgp/response.ts create mode 100644 tests/e2e/bgp-data.spec.ts diff --git a/app/(main)/bgp/page.tsx b/app/(main)/bgp/page.tsx index d75e119d..6eb02d6e 100644 --- a/app/(main)/bgp/page.tsx +++ b/app/(main)/bgp/page.tsx @@ -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 = { + 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 = { 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 ( - - - {s.label} - - ) -} - 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[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("all") const [typeFilter, setTypeFilter] = useState("all") @@ -370,7 +327,7 @@ function SessionsTab({ sessions }: { sessions: BgpSession[] }) { } /> - + ) @@ -656,6 +613,11 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [ ] export default function BgpPage() { + const { mode, backendUrl } = useDataSource() + return +} + +function BgpPageContent() { const [activeTab, setActiveTab] = useState("sessions") const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) @@ -663,10 +625,12 @@ export default function BgpPage() { const isLive = mode === "live" const [liveSessions, setLiveSessions] = useState([]) - const [liveServers, setLiveServers] = useState([]) - const [loading, setLoading] = useState(false) + const [liveServers, setLiveServers] = useState([]) + const [loading, setLoading] = useState(isLive) const [fetchedAt, setFetchedAt] = useState(null) const [liveError, setLiveError] = useState(null) + const [failures, setFailures] = useState([]) + 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(backendUrl, "/api/bgp/sessions"), - requestJson(backendUrl, "/api/servers"), + requestJson(backendUrl, "/api/bgp/catalog"), + requestJson(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 ( - - } /> @@ -770,7 +737,7 @@ export default function BgpPage() {
{TABS.map(t => ( -
)} - {isLive && !loading && fetchedAt && !liveError && ( -
- - - Живые данные · {serverCount} серверов · обновлено {fetchedAt.toLocaleTimeString("ru")} - - + {isLive && fetchedAt && ( +
+ Ответ каталога получен . + Это время получения браузером, не время измерения RouterOS.
)} - {isLive && liveError && ( - + {isLive && (liveError || failures.length > 0) && ( + - Ошибка загрузки: {liveError} + {liveError ? "Не удалось загрузить BGP" : "Не удалось опросить часть роутеров BGP"} + + {liveError ?? failures.map(f => f.serverName + ": " + f.error).join("; ")}. {hasLoaded ? "Показаны последние полученные данные; статус отказавших устройств неизвестен." : "Данные пока неизвестны."} + + )} - {isLive && !loading && allSessions.length === 0 && !liveError && fetchedAt && ( -
- BGP не настроен ни на одном сервере -
- )} - {isLive && !loading && allSessions.length > 0 && sessions.length === 0 && !liveError && ( -
- На выбранном сервере нет BGP-сессий -
- )} {mode === "mock" && ( Моковые данные @@ -835,21 +788,22 @@ export default function BgpPage() { { id: "sessions", label: "Сессий всего", - value: sessions.length, + value: incomplete ? "—" : sessions.length, + hint: incomplete ? "неизвестно" : undefined, icon: , iconClassName: "text-muted-foreground", }, { id: "established", label: "Established", - value: established, + value: incomplete ? "—" : established, icon: , iconClassName: "text-success", }, { id: "not-estab", label: "Не установлено", - value: notEstab, + value: incomplete ? "—" : notEstab, icon: , 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: , iconClassName: "text-info", }, @@ -865,7 +819,7 @@ export default function BgpPage() { /> {/* alert: not-established sessions */} - {notEstab > 0 && ( + {!incomplete && notEstab > 0 && ( @@ -878,9 +832,9 @@ export default function BgpPage() { )} {/* tab content */} - {activeTab === "sessions" && } - {activeTab === "routers" && } - {activeTab === "analytics" && } + {activeTab === "sessions" && } + {activeTab === "routers" && (incomplete ?

Сводка по роутерам неизвестна до успешного опроса. Последние сессии доступны в таблице.

: )} + {activeTab === "analytics" && (incomplete ?

Аналитика неизвестна до успешного опроса. Последние сессии доступны в таблице.

: )}
diff --git a/components/data-grids/bgp-sessions-data-grid.tsx b/components/data-grids/bgp-sessions-data-grid.tsx index d47dc3d9..3f87fd34 100644 --- a/components/data-grids/bgp-sessions-data-grid.tsx +++ b/components/data-grids/bgp-sessions-data-grid.tsx @@ -22,6 +22,7 @@ import { fmtBgpNum } from "@/lib/bgp/helpers" import { ChevronDownIcon, ChevronRightIcon, NetworkIcon } from "lucide-react" const STATE_STYLE: Record = { + 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[]>( () => [ { @@ -272,7 +274,7 @@ function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) { return ( } - title="Нет BGP-сессий" + title={emptyTitle ?? "Нет BGP-сессий"} description="Измените фильтры или проверьте подключение к роутерам" className="border-0 py-10" /> diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index f226ebec..4a1d73ce 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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Б. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index d572e9f0..e6f28599 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -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/свежесть и визуальная приёмка. diff --git a/lib/bgp/response.ts b/lib/bgp/response.ts new file mode 100644 index 00000000..3d7c20f3 --- /dev/null +++ b/lib/bgp/response.ts @@ -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 +export type BgpFailure = z.infer["failures"][number] diff --git a/lib/bgp/types.ts b/lib/bgp/types.ts index 5b21170e..f50dd06a 100644 --- a/lib/bgp/types.ts +++ b/lib/bgp/types.ts @@ -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" diff --git a/tests/e2e/bgp-data.spec.ts b/tests/e2e/bgp-data.spec.ts new file mode 100644 index 00000000..8a6a6906 --- /dev/null +++ b/tests/e2e/bgp-data.spec.ts @@ -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() +}) diff --git a/tests/e2e/routing-tables.spec.ts b/tests/e2e/routing-tables.spec.ts index 8b4f58d9..0744f09a 100644 --- a/tests/e2e/routing-tables.spec.ts +++ b/tests/e2e/routing-tables.spec.ts @@ -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)