From 7a68ae81841b1a96e947fab37429f68431e92325 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 20:09:00 +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=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B1=D1=80=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=83?= =?UTF-8?q?=D0=B7=D0=B5=D0=BB=20OSPF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/ospf/page.tsx | 72 ++++++++++++------- .../data-grids/ospf-neighbors-data-grid.tsx | 4 +- .../data-grids/ospf-routes-data-grid.tsx | 5 +- components/data-page-toolbar.tsx | 3 + docs/ux-ui-progress.md | 9 ++- docs/ux-ui-stage-10.md | 4 ++ lib/ospf-url-state.test.ts | 10 ++- lib/ospf-url-state.ts | 8 ++- tests/e2e/ospf-links.spec.ts | 30 ++++++++ 9 files changed, 113 insertions(+), 32 deletions(-) diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index 1fa7eddf..19ad863d 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -7,6 +7,7 @@ import { useSearchParams } from "next/navigation" import { readOspfUrl, patchOspfUrl, type OspfTab } from "@/lib/ospf-url-state" import { ospfCatalogSchema, ospfServersSchema, ospfOptimizeSchema, type OspfCatalog } from "@/lib/ospf-response" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { DataPageToolbar } from "@/components/data-page-toolbar" import { PageHeader } from "@/components/page-header" import { DataPageCard } from "@/components/data-page-card" import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid" @@ -556,7 +557,7 @@ function NodeDetailPanel({ node.site === "FRA" ? "#b45309" : "#065f46" return ( -
{node.label}

Router-ID: {routerId}

-
@@ -635,6 +636,7 @@ function InterfacesTab({ backendUrl, dataReady, dataIncomplete, + hasSearch, onOperationBusy, onOperationConfirmed, }: { @@ -644,6 +646,7 @@ function InterfacesTab({ backendUrl: string dataReady: boolean dataIncomplete: boolean + hasSearch: boolean onOperationConfirmed: (id: number) => void onOperationBusy: (busy: boolean) => void }) { @@ -762,6 +765,7 @@ function InterfacesTab({ return (
+ {isLive && filterServerId !== ALL_SERVERS_ID &&

Оптимизация применяется ко всем интерфейсам выбранного устройства.

} {isLive && filterServerId !== ALL_SERVERS_ID && !dataReady &&

Оптимизация недоступна до полного успешного опроса и подтверждения costs/состояний интерфейсов с правом записи.

} {operationError && Оптимизация OSPF не подтверждена{operationError}}
@@ -784,7 +788,7 @@ function InterfacesTab({ {grouped.length === 0 && !dataIncomplete && (
- OSPF интерфейсы не настроены ни на одном сервере + {hasSearch ? "Нет интерфейсов по текущему поиску" : "OSPF интерфейсы не настроены ни на одном сервере"}
)} @@ -887,7 +891,7 @@ function InterfacesTab({ // ─── neighbors tab ──────────────────────────────────────────────────────────── function NeighborsTab({ - neighbors, items, graphNodes, graphEdges, routerIds, dataIncomplete, graphUnknown, + neighbors, items, graphNodes, graphEdges, routerIds, dataIncomplete, graphUnknown, selectedId, onSelect, hasSearch, }: { neighbors: OspfNeighbor[] items: OspfItem[] @@ -896,9 +900,11 @@ function NeighborsTab({ routerIds: Record dataIncomplete: boolean graphUnknown: boolean + selectedId: string | null + onSelect: (id: string | null) => void + hasSearch: boolean }) { const [highlightId, setHighlightId] = useState(null) - const [selectedId, setSelectedId] = useState(null) const [showDots, setShowDots] = useState(true) dataIncomplete = dataIncomplete || neighbors.some(n => n.state === "Unknown") @@ -934,6 +940,7 @@ function NeighborsTab({ ]} /> + {selectedId && !graphUnknown && !graphNodes.some(node => node.id === selectedId) && Узел OSPF недоступенУзел из ссылки отсутствует в текущем срезе.} {graphUnknown &&

Топология OSPF неизвестна до успешного опроса всех устройств.

} {!graphUnknown && graphNodes.length > 0 && (
@@ -946,7 +953,7 @@ function NeighborsTab({ highlightId={highlightId} selectedId={selectedId} showDots={showDots} - onNodeClick={setSelectedId} + onNodeClick={onSelect} />
@@ -981,8 +988,9 @@ function NeighborsTab({ @@ -992,7 +1000,7 @@ function NeighborsTab({ // ─── routes tab (mock) ──────────────────────────────────────────────────────── -function RoutesTab({ routes }: { routes: OspfRoute[] }) { +function RoutesTab({ routes, hasSearch }: { routes: OspfRoute[]; hasSearch: boolean }) { const typeCount = Object.entries( routes.reduce>((acc, r) => { acc[r.type] = (acc[r.type] ?? 0) + 1; return acc }, {}) ) @@ -1007,7 +1015,7 @@ function RoutesTab({ routes }: { routes: OspfRoute[] }) { ))}
- +
Типы: @@ -1029,7 +1037,7 @@ function RoutesTab({ routes }: { routes: OspfRoute[] }) { // ─── BFD tab ────────────────────────────────────────────────────────────────── -function BfdTab({ sessions, dataIncomplete }: { sessions: BfdSession[]; dataIncomplete: boolean }) { +function BfdTab({ sessions, dataIncomplete, hasSearch }: { sessions: BfdSession[]; dataIncomplete: boolean; hasSearch: boolean }) { dataIncomplete = dataIncomplete || sessions.some(b => b.state === "Unknown") const upCount = sessions.filter(b => b.state === "Up").length const downCount = sessions.filter(b => b.state === "Down" || b.state === "AdminDown").length @@ -1076,7 +1084,7 @@ function BfdTab({ sessions, dataIncomplete }: { sessions: BfdSession[]; dataInco {sessions.length === 0 && (
- BFD-сессий не обнаружено ни на одном сервере + {hasSearch ? "Нет BFD-сессий по текущему поиску" : "BFD-сессий не обнаружено ни на одном сервере"}
)} @@ -1116,13 +1124,13 @@ function OspfRoute() { const { mode, backendUrl } = useDataSource() const params = useSearchParams() const state = readOspfUrl(new URLSearchParams(params.toString())) - function navigate(values: Record) { + function navigate(values: Record, replace = false) { const href = patchOspfUrl(new URLSearchParams(window.location.search), values) - if (href !== window.location.pathname + window.location.search) window.history.pushState(null, "", href) + if (href !== window.location.pathname + window.location.search) (replace ? window.history.replaceState : window.history.pushState).call(window.history, null, "", href) } - return navigate({ server: id === ALL_SERVERS_ID ? null : id })} onSelectTab={tab => navigate({ tab: tab === "interfaces" ? null : tab })} onResetContext={() => navigate({ server: null, tab: null })} /> + return navigate({ q: value }, true)} onSelectNode={node => navigate({ node })} onSelectServer={id => navigate({ server: id === ALL_SERVERS_ID ? null : id, node: null })} onSelectTab={tab => navigate({ tab: tab === "interfaces" ? null : tab })} onResetContext={() => navigate({ server: null, tab: null, q: null, node: null })} /> } -function OspfPageContent({ filterServerId, activeTab, contextError, onSelectServer, onSelectTab, onResetContext }: { filterServerId: string; activeTab: OspfTab; contextError: string | null; onSelectServer: (id: string) => void; onSelectTab: (tab: OspfTab) => void; onResetContext: () => void }) { +function OspfPageContent({ search, selectedNodeId, onSearch, onSelectNode, filterServerId, activeTab, contextError, onSelectServer, onSelectTab, onResetContext }: { search: string; selectedNodeId: string | null; onSearch: (value: string) => void; onSelectNode: (id: string | null) => void; filterServerId: string; activeTab: OspfTab; contextError: string | null; onSelectServer: (id: string) => void; onSelectTab: (tab: OspfTab) => void; onResetContext: () => void }) { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" @@ -1250,10 +1258,19 @@ function OspfPageContent({ filterServerId, activeTab, contextError, onSelectServ const missingServer = filterServerId !== ALL_SERVERS_ID && (!isLive || Boolean(liveData)) && !ospfServers.some(server => server.id === filterServerId) const unavailable = Boolean(contextError) || missingServer const viewIncomplete = incomplete || unavailable - const displayItems = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId) - const displayNeighbors = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId) - const displayBfdSessions = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId) - const displayRoutes = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? routes : routes.filter(r => r.serverId === filterServerId) + const scopedItems = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId) + const scopedNeighbors = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId) + const scopedBfdSessions = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId) + const scopedRoutes = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? routes : routes.filter(r => r.serverId === filterServerId) + const query = search.trim().toLocaleLowerCase("ru") + const matches = (...fields: (string | number | null | undefined)[]) => !query || fields.some(field => String(field ?? "").toLocaleLowerCase("ru").includes(query)) + const displayItems = scopedItems.filter(i => matches(i.routerLabel, i.interfaceName, i.area, i.type)) + const displayNeighbors = scopedNeighbors.filter(n => matches(n.localLabel, n.localIface, n.remoteRouterId, n.area, n.state)) + const displayBfdSessions = scopedBfdSessions.filter(b => matches(b.serverLabel, b.iface, b.localAddr, b.remoteAddr, b.state)) + const displayRoutes = scopedRoutes.filter(r => matches(r.serverLabel, r.destination, r.nextHop, r.via, r.area, r.type)) + const displayGraph = isLive || filterServerId !== ALL_SERVERS_ID || query ? buildLiveGraph(displayNeighbors) : { nodes: graphNodes, edges: graphEdges } + const visibleCount = activeTab === "interfaces" ? displayItems.length : activeTab === "neighbors" ? displayNeighbors.length : activeTab === "bfd" ? displayBfdSessions.length : displayRoutes.length + const scopeCount = activeTab === "interfaces" ? scopedItems.length : activeTab === "neighbors" ? scopedNeighbors.length : activeTab === "bfd" ? scopedBfdSessions.length : scopedRoutes.length const ospfRailItems = useMemo(() => ( ospfServers.map((s) => { @@ -1323,6 +1340,7 @@ function OspfPageContent({ filterServerId, activeTab, contextError, onSelectServ
{unavailable && Контекст OSPF недоступен{contextError ?? `Сервер ${filterServerId} отсутствует среди доступных устройств.`}} + onSearch("")}>Сбросить поиск : undefined} /> {/* data source banner */} {isLive && loading && (
@@ -1377,7 +1395,8 @@ function OspfPageContent({ filterServerId, activeTab, contextError, onSelectServ filterServerId={filterServerId} backendUrl={backendUrl} dataReady={dataReady} - dataIncomplete={viewIncomplete || unavailable} + hasSearch={Boolean(query)} + dataIncomplete={viewIncomplete} onOperationConfirmed={serverId => { if (pageMounted.current) setWriteReceipt({ serverId, at: new Date().toISOString() }) }} onOperationBusy={busy => { if (!pageMounted.current) return @@ -1390,15 +1409,18 @@ function OspfPageContent({ filterServerId, activeTab, contextError, onSelectServ 0)} /> )} - {activeTab === "routes" && (!viewIncomplete || displayRoutes.length > 0) && } - {activeTab === "bfd" && (!viewIncomplete || displayBfdSessions.length > 0) && } + {activeTab === "routes" && (!viewIncomplete || displayRoutes.length > 0) && } + {activeTab === "bfd" && (!viewIncomplete || displayBfdSessions.length > 0) && }
diff --git a/components/data-grids/ospf-neighbors-data-grid.tsx b/components/data-grids/ospf-neighbors-data-grid.tsx index 958f68a5..2feb3f9c 100644 --- a/components/data-grids/ospf-neighbors-data-grid.tsx +++ b/components/data-grids/ospf-neighbors-data-grid.tsx @@ -45,6 +45,7 @@ function stateClass(state: OspfNeighborRow["state"]) { } interface OspfNeighborsDataGridProps { + emptyTitle?: string neighbors: OspfNeighborRow[] highlightRouterId?: string | null selectedRouterId?: string | null @@ -54,6 +55,7 @@ interface OspfNeighborsDataGridProps { function OspfNeighborsDataGrid({ neighbors, + emptyTitle, highlightRouterId, selectedRouterId, onSelect, @@ -211,7 +213,7 @@ function OspfNeighborsDataGrid({ return ( } - title="Нет OSPF-соседей" + title={emptyTitle ?? "Нет OSPF-соседей"} className="border-0 py-10" /> ) diff --git a/components/data-grids/ospf-routes-data-grid.tsx b/components/data-grids/ospf-routes-data-grid.tsx index 73bcd706..928c703a 100644 --- a/components/data-grids/ospf-routes-data-grid.tsx +++ b/components/data-grids/ospf-routes-data-grid.tsx @@ -39,10 +39,11 @@ function routeTypeClass(type: OspfRouteRow["type"]) { } interface OspfRoutesDataGridProps { + emptyTitle?: string routes: OspfRouteRow[] } -function OspfRoutesDataGrid({ routes }: OspfRoutesDataGridProps) { +function OspfRoutesDataGrid({ routes, emptyTitle }: OspfRoutesDataGridProps) { const columns = useMemo[]>( () => [ { @@ -153,7 +154,7 @@ function OspfRoutesDataGrid({ routes }: OspfRoutesDataGridProps) { return ( } - title="Нет OSPF-маршрутов" + title={emptyTitle ?? "Нет OSPF-маршрутов"} className="border-0 py-10" /> ) diff --git a/components/data-page-toolbar.tsx b/components/data-page-toolbar.tsx index 733a381a..d87d2bb3 100644 --- a/components/data-page-toolbar.tsx +++ b/components/data-page-toolbar.tsx @@ -30,6 +30,7 @@ interface DataPageToolbarProps { search?: string onSearchChange?: (value: string) => void searchPlaceholder?: string + searchLabel?: string segmented?: { value: T onChange: (value: T) => void @@ -46,6 +47,7 @@ function DataPageToolbar({ search, onSearchChange, searchPlaceholder = "Поиск…", + searchLabel, segmented, filterControl, countLabel, @@ -69,6 +71,7 @@ function DataPageToolbar({ onSearchChange(e.target.value)} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 3b0e5faa..ded3340a 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.20, URL устройства/вкладки OSPF; далее поиск/layout | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.21, поиск и узел OSPF в URL; далее метаданные/layout | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.21 — поиск и выбранный узел OSPF (2026-09-30) + +- Поиск текущего раздела и node сохраняются в URL, переживают reload/history. Устройство/таблица/топология используют текущий срез; invalid/repeated/oversized q/node блокируют контекст. Недоступный узел закрывается отдельным действием без сброса устройства. +- Пустой поиск отличается от отсутствия конфигурации. Подпись «N из M», сброс только поиска и доступное имя input добавлены через существующий DataPageToolbar. Область операции обозначена: все интерфейсы устройства, независимо от поиска; readiness проверяет полный каталог устройства. +- Проверки: 27 основных E2E плюс выбранный узел/reload/back (28 разных сценариев), три unit, TS/lint и production frontend (34 страницы) прошли. Новый тест нашёл безымянную кнопку закрытия; aria-label исправлен, сценарий повторно прошёл. Один прежний warning frontend. Компоненты ReUI/тема не изменены. +- Далее дополнительные неизвестные метаданные (тип маршрута, multihop, Router ID) и desktop/mobile/keyboard приёмка. Последнее чтение квоты: 14% окна использовано. Локальный коммит без push. + ## Блок 10.20 — URL-контекст и завершение OSPF-записи после history (2026-09-30) - Устройство/вкладка контролируются URL; reload/back восстанавливают выбор, другие параметры сохранены. Повторные/невалидные параметры и отсутствующее устройство дают явный alert, не раскрывают общий каталог и не показывают нулевую сводку; reset исправляет только этот контекст. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 7c6ea183..e6ce1df2 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -132,3 +132,7 @@ Lock/readiness, существующее mm:servers:write, валидация п ## 10.20. URL и завершение записи после history Device/tab сохранены в URL; invalid/missing context не расширяет срез. Unmounted child освобождает lock только живого родителя, который перечитывает каталог; source unmount игнорирует старый callback. 17 основных + 8 source E2E, два unit, TS/lint прошли. Далее search/object context/layout. + +## 10.21. Поиск и узел + +q/node сохраняются вместе с server/tab. Таблица и топология отражают один срез; пустой поиск не утверждает отсутствие конфигурации. 28 разных E2E, 3 unit, TS/lint и frontend production прошли. Accessible search/close; операция явно относится ко всем интерфейсам устройства. Далее metadata/layout/keyboard. diff --git a/lib/ospf-url-state.test.ts b/lib/ospf-url-state.test.ts index a5e29278..3a5111e5 100644 --- a/lib/ospf-url-state.test.ts +++ b/lib/ospf-url-state.test.ts @@ -3,10 +3,18 @@ import { test } from "node:test" import { readOspfUrl, patchOspfUrl } from "./ospf-url-state" test("OSPF device/tab URL preserves unrelated context", () => { const url=patchOspfUrl(new URLSearchParams("keep=x"),{server:"2",tab:"bfd"}) - assert.deepEqual(readOspfUrl(new URL(url,"http://test").searchParams),{server:"2",tab:"bfd",error:null}) + assert.deepEqual(readOspfUrl(new URL(url,"http://test").searchParams),{server:"2",tab:"bfd",search:"",node:null,error:null}) assert.equal(patchOspfUrl(new URL(url,"http://test").searchParams,{server:null,tab:null}),"/ospf?keep=x") }) test("invalid or repeated context is explicit instead of widening to all",()=>{ for(const raw of ["server=a&server=b","tab=bfd&tab=neighbors","tab=broken","server=", "server="+"x".repeat(65),"server=%2F"]) assert.ok(readOspfUrl(new URLSearchParams(raw)).error) assert.equal(readOspfUrl(new URLSearchParams("server=srv1&tab=neighbors")).error,null) }) + +test("OSPF search/node roundtrip and invalid bounds",()=>{ + const params=new URLSearchParams({server:"1",tab:"neighbors",q:"ether & 1",node:"remote-192.0.2.2",keep:"x"}) + const state=readOspfUrl(params) + assert.equal(state.search,"ether & 1");assert.equal(state.node,"remote-192.0.2.2");assert.equal(state.error,null) + for(const raw of ["q=x&q=y","node=1&node=2","node=%2F","q="+"x".repeat(257),"node="+"x".repeat(129)])assert.ok(readOspfUrl(new URLSearchParams(raw)).error) + assert.equal(patchOspfUrl(params,{q:null,node:null}),"/ospf?server=1&tab=neighbors&keep=x") +}) diff --git a/lib/ospf-url-state.ts b/lib/ospf-url-state.ts index f31d946c..e33b3723 100644 --- a/lib/ospf-url-state.ts +++ b/lib/ospf-url-state.ts @@ -2,12 +2,16 @@ export const ospfTabs = ["interfaces", "neighbors", "routes", "bfd"] as const export type OspfTab = typeof ospfTabs[number] export function readOspfUrl(params: URLSearchParams) { const server = params.get("server") ?? "all" + const search = params.get("q") ?? "" + const node = params.get("node") const rawTab = params.get("tab") ?? "interfaces" let error: string | null = null - if (["server", "tab"].some(key => params.getAll(key).length > 1)) error = "В ссылке повторяется контекст OSPF." + if (["server", "tab", "q", "node"].some(key => params.getAll(key).length > 1)) error = "В ссылке повторяется контекст OSPF." else if (!/^[a-zA-Z0-9_.:-]{1,64}$/.test(server)) error = "В ссылке указан некорректный сервер OSPF." else if (!(ospfTabs as readonly string[]).includes(rawTab)) error = "В ссылке указана неизвестная вкладка OSPF." - return { server, tab: (ospfTabs as readonly string[]).includes(rawTab) ? rawTab as OspfTab : "interfaces" as OspfTab, error } + else if (search.length > 256) error = "Поиск OSPF в ссылке слишком длинный." + else if (node !== null && !/^[a-zA-Z0-9_.:-]{1,128}$/.test(node)) error = "В ссылке указан некорректный узел OSPF." + return { server, search, node, tab: (ospfTabs as readonly string[]).includes(rawTab) ? rawTab as OspfTab : "interfaces" as OspfTab, error } } export function patchOspfUrl(params: URLSearchParams, changes: Record) { const next = new URLSearchParams(params) diff --git a/tests/e2e/ospf-links.spec.ts b/tests/e2e/ospf-links.spec.ts index 5ee15997..d0c7d239 100644 --- a/tests/e2e/ospf-links.spec.ts +++ b/tests/e2e/ospf-links.spec.ts @@ -36,3 +36,33 @@ for(const fails of [false,true])test("OSPF: history во время записи await expect(page.getByText("Старая ошибка",{exact:false})).toHaveCount(0) await expect(page.locator("[data-sonner-toast]").filter({hasText:"API подтвердил оптимизацию OSPF"})).toHaveCount(0) }) + +test("OSPF: поиск из URL не выдаёт пустой срез за отсутствие настройки",async({page})=>{ + await setup(page);await page.goto("/ospf?server=2&q=ether2&keep=x") + await expect(page.getByPlaceholder("Поиск в текущем разделе OSPF…")).toHaveValue("ether2") + await expect(page.getByText("ether2",{exact:true})).toBeVisible();await expect(page.getByText("ether1",{exact:true})).toHaveCount(0) + await page.getByPlaceholder("Поиск в текущем разделе OSPF…").fill("несуществующий") + await expect(page.getByText("Нет интерфейсов по текущему поиску",{exact:true})).toBeVisible() + await expect(page.getByText("OSPF интерфейсы не настроены ни на одном сервере",{exact:true})).toHaveCount(0) + await page.reload();await expect(page.getByText("Нет интерфейсов по текущему поиску",{exact:true})).toBeVisible() + await page.getByRole("button",{name:"Сбросить поиск",exact:true}).click() + await expect(page).toHaveURL(/server=2/);await expect(page).toHaveURL(/keep=x/);await expect(page.getByText("ether2",{exact:true})).toBeVisible() +}) +test("OSPF: недоступный узел из URL исправляется без сброса устройства",async({page})=>{ + await setup(page);await page.goto("/ospf?server=2&tab=neighbors&node=999&keep=x") + const alert=page.getByRole("alert").filter({hasText:"Узел OSPF недоступен"});await expect(alert).toBeVisible() + await alert.getByRole("button",{name:"Закрыть узел OSPF"}).click() + await expect(alert).toHaveCount(0);await expect(page).toHaveURL(/server=2/);await expect(page).toHaveURL(/keep=x/) +}) + +test("OSPF: выбранный узел и поиск сохраняются после reload/back",async({page})=>{ + await setup(page) + await page.route("**/api/ospf/catalog",r=>r.fulfill({json:{...catalog,neighbors:[1,2].map(id=>({...host(id),id:"n",address:"198.51.100."+id,routerId:"198.51.100."+id,instance:"default",area:"backbone",areaId:"0.0.0.0",interface:"ether"+id,state:"Full",uptime:"1h",priority:1,stateChanges:0}))}})) + await page.goto("/ospf?tab=neighbors&q=ether1&keep=x") + const rows=page.locator("tr[data-row-id]");await expect(rows).toHaveCount(1) + await rows.click();await expect(page).toHaveURL(/node=1/) + const details=page.getByRole("region",{name:"Детали узла OSPF"});await expect(details).toBeVisible() + await page.reload();await expect(details).toBeVisible();await expect(rows).toHaveCount(1) + await page.getByRole("button",{name:"Закрыть",exact:true}).click();await expect(details).toHaveCount(0) + await page.goBack();await expect(details).toBeVisible();await expect(page).toHaveURL(/keep=x/) +})