fix(ui): сохранять контекст и свежесть опроса BGP

This commit is contained in:
Denozordec committed 2026-09-30 17:05:37 +07:00
1 parent 8fdc4b917b
commit 2e17cbe926
8 files changed
+182 -19

No files matched your search

+43 -15
View File
@@ -1,6 +1,7 @@
"use client" "use client"
import { useState, useMemo, useEffect } from "react" import { Suspense, useState, useMemo, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid" import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
@@ -258,7 +259,7 @@ const BGP_FILTER_ACCESSORS = {
afi: (s: BgpSession) => s.afi, afi: (s: BgpSession) => s.afi,
} }
function SessionsTab({ sessions, emptyTitle }: { sessions: BgpSession[]; emptyTitle?: string }) { function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession }: { sessions: BgpSession[]; emptyTitle?: string; selectedSessionId: string | null; onSelectSession: (id: string | null) => void }) {
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [stateFilter, setStateFilter] = useState<StateFilter>("all") const [stateFilter, setStateFilter] = useState<StateFilter>("all")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all") const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
@@ -324,7 +325,7 @@ function SessionsTab({ sessions, emptyTitle }: { sessions: BgpSession[]; emptyTi
</div> </div>
} }
/> />
<BgpSessionsDataGrid sessions={filtered} emptyTitle={emptyTitle} /> <BgpSessionsDataGrid sessions={filtered} emptyTitle={emptyTitle} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />
</DataPageCard> </DataPageCard>
</div> </div>
) )
@@ -611,14 +612,33 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
] ]
export default function BgpPage() { export default function BgpPage() {
const { mode, backendUrl } = useDataSource() return <Suspense fallback={<div role="status">Загрузка BGP…</div>}><BgpRoute /></Suspense>
return <BgpPageContent key={JSON.stringify([mode, backendUrl])} />
} }
function BgpRoute() {
function BgpPageContent() { const { mode, backendUrl } = useDataSource()
const [activeTab, setActiveTab] = useState<BgpTab>("sessions") const params = useSearchParams()
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const router = useRouter()
const selectedServerId = params.get("server") ?? ALL_SERVERS_ID
const selectedSessionId = params.get("session")
const tab = params.get("tab")
const activeTab: BgpTab = tab === "routers" || tab === "analytics" ? tab : "sessions"
function navigate(values: Record<string, string | null>) {
const next = new URLSearchParams(params.toString())
for (const [key, value] of Object.entries(values)) {
if (value === null) next.delete(key)
else next.set(key, value)
}
router.push("/bgp" + (next.size ? "?" + next.toString() : ""))
}
return <BgpPageContent key={JSON.stringify([mode, backendUrl])} selectedServerId={selectedServerId} selectedSessionId={selectedSessionId} activeTab={activeTab}
onSelectServer={id => navigate({ server: id === ALL_SERVERS_ID ? null : id, session: null })}
onSelectSession={id => navigate({ session: id })}
onSelectTab={tab => navigate({ tab: tab === "sessions" ? null : tab })} />
}
function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSelectServer, onSelectSession, onSelectTab }: {
selectedServerId: string; selectedSessionId: string | null; activeTab: BgpTab;
onSelectServer: (id: string) => void; onSelectSession: (id: string | null) => void; onSelectTab: (tab: BgpTab) => void;
}) {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
@@ -629,6 +649,7 @@ function BgpPageContent() {
const [liveError, setLiveError] = useState<string | null>(null) const [liveError, setLiveError] = useState<string | null>(null)
const [failures, setFailures] = useState<BgpFailure[]>([]) const [failures, setFailures] = useState<BgpFailure[]>([])
const [hasLoaded, setHasLoaded] = useState(false) const [hasLoaded, setHasLoaded] = useState(false)
const [readReceipts, setReadReceipts] = useState<Record<string, string>>({})
const [fetchTick, setFetchTick] = useState(0) const [fetchTick, setFetchTick] = useState(0)
useEffect(() => { useEffect(() => {
@@ -660,7 +681,9 @@ function BgpPageContent() {
setFailures(catalog.failures) setFailures(catalog.failures)
setLiveError(null) setLiveError(null)
setHasLoaded(true) setHasLoaded(true)
setFetchedAt(new Date()) const receivedAt = new Date()
setFetchedAt(receivedAt)
setReadReceipts(previous => ({ ...Object.fromEntries(Object.entries(previous).filter(([id]) => enabled.has(Number(id)))), ...Object.fromEntries([...enabled].filter(id => !failed.has(String(id))).map(id => [String(id), receivedAt.toISOString()])) }))
setLoading(false) setLoading(false)
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
@@ -674,7 +697,8 @@ function BgpPageContent() {
const unknownIds = useMemo(() => new Set(isLive ? (liveError ? liveServers.map(s => s.id) : failures.map(f => String(f.serverId))) : []), [isLive, liveError, liveServers, failures]) 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 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 unavailableContext = isLive && hasLoaded && selectedServerId !== ALL_SERVERS_ID && !liveServers.some(s => s.id === selectedServerId)
const incomplete = isLive && (unavailableContext || !hasLoaded || Boolean(liveError) || (selectedServerId === ALL_SERVERS_ID ? failures.length > 0 : unknownIds.has(selectedServerId)))
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled) const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId = selectedServerId const effectiveServerId = selectedServerId
@@ -711,9 +735,10 @@ function BgpPageContent() {
return ( return (
<ServerRailLayout <ServerRailLayout
preserveSelection
items={railItems} items={railItems}
selectedId={effectiveServerId} selectedId={effectiveServerId}
onSelect={setSelectedServerId} onSelect={onSelectServer}
showAll showAll
allCount={displayServers.length} allCount={displayServers.length}
loading={isLive && loading && displayServers.length === 0} loading={isLive && loading && displayServers.length === 0}
@@ -735,7 +760,7 @@ function BgpPageContent() {
<div className="border-b bg-background shrink-0"> <div className="border-b bg-background shrink-0">
<div className="flex items-center px-6"> <div className="flex items-center px-6">
{TABS.map(t => ( {TABS.map(t => (
<button key={t.id} aria-pressed={activeTab === t.id} onClick={() => setActiveTab(t.id)} <button key={t.id} aria-pressed={activeTab === t.id} onClick={() => onSelectTab(t.id)}
className={cn( className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px", "flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id activeTab === t.id
@@ -751,6 +776,8 @@ function BgpPageContent() {
> >
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{unavailableContext && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Устройство BGP недоступно в этом каталоге</AlertTitle><AlertDescription>Контекст {selectedServerId} сохранён. Выберите доступное устройство.<Button variant="outline" size="sm" onClick={() => onSelectServer(ALL_SERVERS_ID)}>Все устройства BGP</Button></AlertDescription></Alert>}
{selectedSessionId && hasLoaded && !sessions.some(s => s.id === selectedSessionId) && <Alert variant="warning"><AlertCircleIcon /><AlertDescription>Сессия из ссылки отсутствует в выбранном снимке. Контекст ссылки сохранён.<Button variant="outline" size="sm" onClick={() => onSelectSession(null)}>Все сессии BGP</Button></AlertDescription></Alert>}
{/* data source banner */} {/* data source banner */}
{isLive && loading && ( {isLive && loading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -762,6 +789,7 @@ function BgpPageContent() {
<div role="group" aria-label="Свежесть BGP" className="text-xs text-muted-foreground"> <div role="group" aria-label="Свежесть BGP" className="text-xs text-muted-foreground">
Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>. Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>.
Это время получения браузером, не время измерения RouterOS. Это время получения браузером, не время измерения RouterOS.
{displayServers.filter(s => effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId).map(s => <p key={s.id}>{s.name}: {readReceipts[s.id] ? <time data-bgp-server={s.id} dateTime={readReceipts[s.id]}>{new Date(readReceipts[s.id]).toLocaleTimeString("ru")}</time> : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}</p>)}
</div> </div>
)} )}
{isLive && (liveError || failures.length > 0) && ( {isLive && (liveError || failures.length > 0) && (
@@ -830,7 +858,7 @@ function BgpPageContent() {
)} )}
{/* tab content */} {/* tab content */}
{activeTab === "sessions" && <SessionsTab sessions={sessions} emptyTitle={incomplete && sessions.length === 0 ? "Данные BGP неизвестны" : undefined} />} {activeTab === "sessions" && <SessionsTab sessions={sessions} emptyTitle={incomplete && sessions.length === 0 ? "Данные BGP неизвестны" : undefined} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />}
{activeTab === "routers" && (incomplete ? <p role="status">Сводка по роутерам неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <RoutersTab sessions={sessions} />)} {activeTab === "routers" && (incomplete ? <p role="status">Сводка по роутерам неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <RoutersTab sessions={sessions} />)}
{activeTab === "analytics" && (incomplete ? <p role="status">Аналитика неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <AnalyticsTab sessions={sessions} />)} {activeTab === "analytics" && (incomplete ? <p role="status">Аналитика неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <AnalyticsTab sessions={sessions} />)}
@@ -102,9 +102,11 @@ function TypeBadge({ type }: { type: BgpType }) {
interface BgpSessionsDataGridProps { interface BgpSessionsDataGridProps {
sessions: BgpSessionRow[] sessions: BgpSessionRow[]
emptyTitle?: string emptyTitle?: string
selectedSessionId?: string | null
onSelectSession?: (id: string | null) => void
} }
function BgpSessionsDataGrid({ sessions, emptyTitle }: BgpSessionsDataGridProps) { function BgpSessionsDataGrid({ sessions, emptyTitle, selectedSessionId, onSelectSession }: BgpSessionsDataGridProps) {
const columns = useMemo<ColumnDef<DataGridFeatures, BgpSessionRow>[]>( const columns = useMemo<ColumnDef<DataGridFeatures, BgpSessionRow>[]>(
() => [ () => [
{ {
@@ -268,6 +270,7 @@ function BgpSessionsDataGrid({ sessions, emptyTitle }: BgpSessionsDataGridProps)
columns, columns,
getRowId: (row) => row.id, getRowId: (row) => row.id,
getRowCanExpand: () => true, getRowCanExpand: () => true,
...(onSelectSession ? { state: { expanded: selectedSessionId ? { [selectedSessionId]: true } : {} } } : {}),
}) })
if (sessions.length === 0) { if (sessions.length === 0) {
@@ -285,7 +288,7 @@ function BgpSessionsDataGrid({ sessions, emptyTitle }: BgpSessionsDataGridProps)
<DataGridShell <DataGridShell
table={table} table={table}
recordCount={sessions.length} recordCount={sessions.length}
onRowClick={(row) => table.getRow(row.id).toggleExpanded()} onRowClick={(row) => onSelectSession ? onSelectSession(row.id === selectedSessionId ? null : row.id) : table.getRow(row.id).toggleExpanded()}
/> />
) )
} }
+8 -1
View File
@@ -26,11 +26,18 @@
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.4, поля BGP; далее URL/свежесть | | 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.5, BGP URL/свежесть; далее ASN/layout |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.5 — URL, свежесть и источник BGP (2026-09-30)
- URL хранит server/session/tab, сохраняет сторонние параметры; раскрытие строки связано с session. Возврат и reload восстанавливают контекст. Недоступное устройство/сессия из ссылки показывают сообщение и явный переход, не подменяются всеми устройствами. Включён preserveSelection установленного rail: тест выявил сброс при пустом начальном inventory.
- Время получения каталога и последнего успешного опроса каждого включённого устройства разделены. Частичный/общий отказ сохраняет прежние отметки; эти времена не объявляются измерением RouterOS.
- 19 различных E2E прошли: 13 BGP/OSPF профиля, два URL/freshness и четыре backend/live/mock late GET в отдельной последовательной конфигурации. После исправления rail повтор URL/routing прошёл; async URL assertions ожидают навигацию. TypeScript/lint чистые.
- Далее — ASN/type unknown вместо fallback 0/eBGP, визуальная приёмка BGP и достоверность OSPF. Фильтры поиска/FilterQuery ещё локальные, их URL-перенос остаётся в этом этапе. Квота на проверках: 69% окна использовано, 26% недельного. Локальный коммит без push.
## Блок 10.4 — неподтверждённые поля и экспорт BGP (2026-09-30) ## Блок 10.4 — неподтверждённые поля и экспорт BGP (2026-09-30)
- Новый каталог использует отдельный parser без defaults старого API: отсутствующие/некорректные таймеры, сообщения, prefix-count и AFI неизвестны. Наблюдаемый 0 сохранён, hold-time infinity отображается как 0. UPDATE-счётчики не объявляются числом префиксов; prefixesTx неизвестен, поскольку фактический каталог отправленных маршрутов не получен. Старый parser/API сохраняют прежнюю совместимость. - Новый каталог использует отдельный parser без defaults старого API: отсутствующие/некорректные таймеры, сообщения, prefix-count и AFI неизвестны. Наблюдаемый 0 сохранён, hold-time infinity отображается как 0. UPDATE-счётчики не объявляются числом префиксов; prefixesTx неизвестен, поскольку фактический каталог отправленных маршрутов не получен. Старый parser/API сохраняют прежнюю совместимость.
+4
View File
@@ -56,3 +56,7 @@
## Блок 10.4 — поля BGP ## Блок 10.4 — поля BGP
Новый parser сохраняет неизвестные таймеры/счётчики/AFI, не подменяет префиксы UPDATE-счётчиками; legacy API сохранён. Удалён придуманный экспорт. 13 E2E/unit/live-maps/TypeScript/lint/backend build прошли. Далее — URL/свежесть/источник/layout. Новый parser сохраняет неизвестные таймеры/счётчики/AFI, не подменяет префиксы UPDATE-счётчиками; legacy API сохранён. Удалён придуманный экспорт. 13 E2E/unit/live-maps/TypeScript/lint/backend build прошли. Далее — URL/свежесть/источник/layout.
## Блок 10.5 — BGP URL/свежесть
server/session/tab и возврат/reload сохраняют контекст; недоступный объект не подменяется. Время успешного опроса каждого устройства не обновляется при отказе. 19 различных E2E/TypeScript/lint прошли. Далее — ASN/type, URL фильтров и layout.
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "@playwright/test"
import base from "./playwright.config"
const server = base.webServer
if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер")
export default defineConfig({
...base,
use: { ...base.use, baseURL: "http://127.0.0.1:3101" },
testIgnore: [],
testMatch: "bgp-context.spec.ts",
webServer: { ...server, command: "node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101", url: "http://127.0.0.1:3101", env: { ...server.env, NEXT_PUBLIC_BACKEND_URL: "local" } },
})
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", testDir: "./tests/e2e",
testIgnore: ["**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], testIgnore: ["**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
retries: 0, retries: 0,
+43
View File
@@ -0,0 +1,43 @@
import { test, expect } from "@playwright/test"
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
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: ["Тест capability"], lastError: null })
for (const change of ["backend", "mode"]) for (const fails of [false, true]) {
test(`BGP: позднее чтение после смены ${change}/${fails}`, async ({ page }) => {
await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } }))
await page.route("**/api/**", r => {
const url = new URL(r.request().url()), path = url.pathname
return r.fulfill({ headers, json: path === "/api/bgp/catalog" ? { sessions: [session(1, url.port === "8101" ? "Новая BGP" : "Старая BGP")], failures: [] } : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, country: "RU" }] : path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/settings")
await page.getByRole("link", { name: /^BGP/ }).click()
await expect(page.locator("tr[data-row-id]")).toContainText("Старая BGP")
let release: (() => void) | undefined, started = false
const gate = new Promise<void>(resolve => { release = resolve })
await page.route("**/api/bgp/catalog", async r => {
if (new URL(r.request().url()).port !== "8000") return r.fallback()
started = true
await gate
return r.fulfill(fails ? { headers, status: 503, json: { error: "Старая ошибка BGP" } } : { headers, json: { sessions: [], failures: [] } })
})
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect.poll(() => started).toBe(true)
await page.goBack()
await expect(page).toHaveURL(/settings/)
if (change === "backend") {
const input = page.getByPlaceholder("http://localhost:8000")
await input.fill("http://localhost:8101")
await input.press("Enter")
} else await page.getByRole("button", { name: "Моковые", exact: true }).click()
await page.getByRole("link", { name: /^BGP/ }).click()
if (change === "backend") await expect(page.locator("tr[data-row-id]")).toContainText("Новая BGP")
else await expect(page.getByText("Моковые данные", { exact: true })).toBeVisible()
const response = page.waitForResponse(r => r.url().includes(":8000/api/bgp/catalog"))
release!()
await (await response).finished()
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
if (change === "backend") await expect(page.locator("tr[data-row-id]")).toContainText("Новая BGP")
await expect(page.getByText("Не удалось загрузить BGP", { exact: false })).toHaveCount(0)
})
}
+66
View File
@@ -0,0 +1,66 @@
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: ["Тест capability"], lastError: null })
test("BGP: URL устройства/сессии/вкладки, возврат и неизвестный контекст", async ({ page }) => {
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" ? { sessions: [session(1), session(2)], failures: [] } : path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/bgp?server=1&session=1-%2A1&keep=x")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await expect(page.getByText("Тест capability", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Аналитика", exact: true }).click()
await expect(page).toHaveURL(/tab=analytics/)
await page.getByRole("option").filter({ hasText: "Роутер 2" }).click()
await expect(page).toHaveURL(/server=2/)
await expect.poll(() => new URL(page.url()).searchParams.has("session")).toBe(false)
expect(new URL(page.url()).searchParams.get("keep")).toBe("x")
await page.goBack()
await expect(page).toHaveURL(/server=1/)
await page.getByRole("button", { name: "Сессии", exact: true }).click()
await expect(page.getByText("Тест capability", { exact: true })).toBeVisible()
await page.reload()
await expect(page.getByText("Тест capability", { exact: true })).toBeVisible()
await page.goto("/bgp?server=99")
await expect(page.getByRole("alert").filter({ hasText: "Устройство BGP недоступно" })).toBeVisible()
await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
await expect(page).toHaveURL(/server=99/)
await page.getByRole("button", { name: "Все устройства BGP" }).click()
await expect(page.locator("tr[data-row-id]")).toHaveCount(2)
await page.goto("/bgp?server=1&session=missing")
await expect(page.getByRole("alert").filter({ hasText: "Сессия из ссылки отсутствует" })).toBeVisible()
await page.getByRole("button", { name: "Все сессии BGP" }).click()
await expect.poll(() => new URL(page.url()).searchParams.has("session")).toBe(false)
})
test("BGP: время успешного опроса устройства сохраняется при отказе", async ({ page }) => {
await page.clock.install({ time: new Date("2026-09-30T05:00:00Z") })
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/bgp/catalog" && phase === 2) return r.fulfill({ status: 503, json: { error: "Общий отказ BGP" } })
return r.fulfill({ json: path === "/api/servers" ? [server(1), server(2)] : path === "/api/bgp/catalog" ? { sessions: phase === 1 ? [session(1)] : [session(1), session(2)], failures: phase === 1 ? [{ serverId: 2, serverName: "Роутер 2", error: "Таймаут тест" }] : [] } : path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/bgp")
const group = page.getByRole("group", { name: "Свежесть BGP" })
const first = await group.locator('[data-bgp-server="2"]').getAttribute("datetime")
phase = 1
await page.clock.fastForward(60_000)
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Таймаут тест" })).toBeVisible()
await expect(group.locator('[data-bgp-server="2"]')).toHaveAttribute("datetime", first!)
await expect(group.locator('[data-bgp-server="1"]')).not.toHaveAttribute("datetime", first!)
const after = await group.locator('[data-bgp-server="1"]').getAttribute("datetime")
phase = 2
await page.clock.fastForward(60_000)
await page.getByRole("button", { name: "Повторить загрузку BGP" }).click()
await expect(page.getByRole("alert").filter({ hasText: "Общий отказ BGP" })).toBeVisible()
await expect(group.locator('[data-bgp-server="1"]')).toHaveAttribute("datetime", after!)
await expect(group.locator('[data-bgp-server="2"]')).toHaveAttribute("datetime", first!)
phase = 3
await page.getByRole("button", { name: "Повторить загрузку BGP" }).click()
await expect(page.getByRole("alert").filter({ hasText: "Не удалось" })).toHaveCount(0)
await expect(group.locator('[data-bgp-server="2"]')).not.toHaveAttribute("datetime", first!)
})