fix(ui): сохранять контекст и свежесть опроса BGP
This commit is contained in:
1 parent
8fdc4b917b
commit
2e17cbe926
8 files changed
+182
-19
No files matched your search
+43
-15
@@ -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()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 сохраняют прежнюю совместимость.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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" } },
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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!)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user