From 2e17cbe926ca274f81fde063b0a5cd220b76cf93 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 17:05:37 +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=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D1=82=20=D0=B8=20=D1=81=D0=B2=D0=B5=D0=B6=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D0=BF=D1=80=D0=BE=D1=81=D0=B0=20BGP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/bgp/page.tsx | 58 +++++++++++----- .../data-grids/bgp-sessions-data-grid.tsx | 7 +- docs/ux-ui-progress.md | 9 ++- docs/ux-ui-stage-10.md | 4 ++ playwright.bgp-context.config.ts | 12 ++++ playwright.config.ts | 2 +- tests/e2e/bgp-context.spec.ts | 43 ++++++++++++ tests/e2e/bgp-links-freshness.spec.ts | 66 +++++++++++++++++++ 8 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 playwright.bgp-context.config.ts create mode 100644 tests/e2e/bgp-context.spec.ts create mode 100644 tests/e2e/bgp-links-freshness.spec.ts diff --git a/app/(main)/bgp/page.tsx b/app/(main)/bgp/page.tsx index 788337c2..0b43a0ef 100644 --- a/app/(main)/bgp/page.tsx +++ b/app/(main)/bgp/page.tsx @@ -1,6 +1,7 @@ "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 { DataPageToolbar } from "@/components/data-page-toolbar" import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid" @@ -258,7 +259,7 @@ const BGP_FILTER_ACCESSORS = { 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 [stateFilter, setStateFilter] = useState("all") const [typeFilter, setTypeFilter] = useState("all") @@ -324,7 +325,7 @@ function SessionsTab({ sessions, emptyTitle }: { sessions: BgpSession[]; emptyTi } /> - + ) @@ -611,14 +612,33 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [ ] export default function BgpPage() { - const { mode, backendUrl } = useDataSource() - return + return Загрузка BGP…}> } - -function BgpPageContent() { - const [activeTab, setActiveTab] = useState("sessions") - const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) - +function BgpRoute() { + const { mode, backendUrl } = useDataSource() + const params = useSearchParams() + 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) { + 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 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 isLive = mode === "live" @@ -629,6 +649,7 @@ function BgpPageContent() { const [liveError, setLiveError] = useState(null) const [failures, setFailures] = useState([]) const [hasLoaded, setHasLoaded] = useState(false) + const [readReceipts, setReadReceipts] = useState>({}) const [fetchTick, setFetchTick] = useState(0) useEffect(() => { @@ -660,7 +681,9 @@ function BgpPageContent() { setFailures(catalog.failures) setLiveError(null) 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) }) .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 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 effectiveServerId = selectedServerId @@ -711,9 +735,10 @@ function BgpPageContent() { return (
{TABS.map(t => ( - } + {selectedSessionId && hasLoaded && !sessions.some(s => s.id === selectedSessionId) && Сессия из ссылки отсутствует в выбранном снимке. Контекст ссылки сохранён.} {/* data source banner */} {isLive && loading && (
@@ -762,6 +789,7 @@ function BgpPageContent() {
Ответ каталога получен . Это время получения браузером, не время измерения RouterOS. + {displayServers.filter(s => effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId).map(s =>

{s.name}: {readReceipts[s.id] ? : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}

)}
)} {isLive && (liveError || failures.length > 0) && ( @@ -830,7 +858,7 @@ function BgpPageContent() { )} {/* tab content */} - {activeTab === "sessions" && } + {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 1f8b06aa..07c185ad 100644 --- a/components/data-grids/bgp-sessions-data-grid.tsx +++ b/components/data-grids/bgp-sessions-data-grid.tsx @@ -102,9 +102,11 @@ function TypeBadge({ type }: { type: BgpType }) { interface BgpSessionsDataGridProps { sessions: BgpSessionRow[] 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[]>( () => [ { @@ -268,6 +270,7 @@ function BgpSessionsDataGrid({ sessions, emptyTitle }: BgpSessionsDataGridProps) columns, getRowId: (row) => row.id, getRowCanExpand: () => true, + ...(onSelectSession ? { state: { expanded: selectedSessionId ? { [selectedSessionId]: true } : {} } } : {}), }) if (sessions.length === 0) { @@ -285,7 +288,7 @@ function BgpSessionsDataGrid({ sessions, emptyTitle }: BgpSessionsDataGridProps) table.getRow(row.id).toggleExpanded()} + onRowClick={(row) => onSelectSession ? onSelectSession(row.id === selectedSessionId ? null : row.id) : table.getRow(row.id).toggleExpanded()} /> ) } diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 0537c729..f3b84d21 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,18 @@ | 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.4, поля BGP; далее URL/свежесть | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.5, BGP URL/свежесть; далее ASN/layout | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 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) - Новый каталог использует отдельный parser без defaults старого API: отсутствующие/некорректные таймеры, сообщения, prefix-count и AFI неизвестны. Наблюдаемый 0 сохранён, hold-time infinity отображается как 0. UPDATE-счётчики не объявляются числом префиксов; prefixesTx неизвестен, поскольку фактический каталог отправленных маршрутов не получен. Старый parser/API сохраняют прежнюю совместимость. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 944d9deb..81f9b730 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -56,3 +56,7 @@ ## Блок 10.4 — поля BGP Новый 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. diff --git a/playwright.bgp-context.config.ts b/playwright.bgp-context.config.ts new file mode 100644 index 00000000..fc283e0d --- /dev/null +++ b/playwright.bgp-context.config.ts @@ -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" } }, +}) diff --git a/playwright.config.ts b/playwright.config.ts index 810cf386..e5c3523f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test" export default defineConfig({ 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, workers: 1, retries: 0, diff --git a/tests/e2e/bgp-context.spec.ts b/tests/e2e/bgp-context.spec.ts new file mode 100644 index 00000000..85a78d13 --- /dev/null +++ b/tests/e2e/bgp-context.spec.ts @@ -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(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(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) + }) +} diff --git a/tests/e2e/bgp-links-freshness.spec.ts b/tests/e2e/bgp-links-freshness.spec.ts new file mode 100644 index 00000000..aba4f289 --- /dev/null +++ b/tests/e2e/bgp-links-freshness.spec.ts @@ -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!) +})