From 1d522527b4fa61d5315cdc0ede76f5cabb2f0209 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 19:58:43 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=B2=D0=BB=D0=B8=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=20OSPF=20=D0=B8?= =?UTF-8?q?=D0=B7=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/ospf/page.tsx | 94 ++++++++++++++++++---------------- docs/ux-ui-progress.md | 9 +++- docs/ux-ui-stage-10.md | 4 ++ lib/ospf-url-state.test.ts | 12 +++++ lib/ospf-url-state.ts | 16 ++++++ tests/e2e/ospf-context.spec.ts | 1 + tests/e2e/ospf-links.spec.ts | 38 ++++++++++++++ 7 files changed, 130 insertions(+), 44 deletions(-) create mode 100644 lib/ospf-url-state.test.ts create mode 100644 lib/ospf-url-state.ts create mode 100644 tests/e2e/ospf-links.spec.ts diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index c9825098..1fa7eddf 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -2,7 +2,9 @@ import { buildOspfCostIndex, ospfCostKey } from "@/lib/ospf-cost" import { normalizeOspfState, normalizeBfdState, ospfNodeState, type OspfNeighborState, type BfdState } from "@/lib/ospf-state" -import { useMemo, useState, useEffect, useRef } from "react" +import { useMemo, useState, useEffect, useRef, Suspense } from "react" +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 { PageHeader } from "@/components/page-header" @@ -30,7 +32,6 @@ import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data" // ─── types ──────────────────────────────────────────────────────────────────── -type OspfTab = "interfaces" | "neighbors" | "routes" | "bfd" interface OspfServerInfo { id: string // String(serverId) in live mode, routerKey in mock @@ -632,7 +633,6 @@ function InterfacesTab({ isLive, filterServerId, backendUrl, - onLiveDataRefresh, dataReady, dataIncomplete, onOperationBusy, @@ -642,7 +642,6 @@ function InterfacesTab({ isLive: boolean filterServerId: string backendUrl: string - onLiveDataRefresh: () => void dataReady: boolean dataIncomplete: boolean onOperationConfirmed: (id: number) => void @@ -720,24 +719,23 @@ function InterfacesTab({ body: JSON.stringify({ pingWeight: ra.pingWeight }), }, ) - if (!mounted.current) return const data = ospfOptimizeSchema.parse(response) if (String(data.serverId) !== filterServerId) throw new Error("API вернул другой контекст OSPF") const byKey: Record = {} data.interfaces.forEach((row) => { byKey[`${data.serverId}-${row.id}`] = row.optimalCost }) - setLiveOptimalCost(byKey) onOperationConfirmed(data.serverId) + if (!mounted.current) return + setLiveOptimalCost(byKey) toast.success(`API подтвердил оптимизацию OSPF: ${data.optimizedCount} интерфейсов`) - onLiveDataRefresh() } catch (err) { if (!mounted.current) return setOperationError(`API не подтвердил завершение оптимизации: ${err instanceof Error ? err.message : String(err)}. Часть costs могла измениться; выполнен повторный опрос перед новой попыткой.`) - onLiveDataRefresh() } finally { operationLock.current = false - if (mounted.current) { setOptimizing(false); onOperationBusy(false) } + if (mounted.current) setOptimizing(false) + onOperationBusy(false) } } @@ -1112,13 +1110,19 @@ const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [ ] export default function OspfPage() { - const { mode, backendUrl } = useDataSource() - return + return Загрузка контекста OSPF…

}>
} -function OspfPageContent() { - const [activeTab, setActiveTab] = useState("interfaces") - const [filterServerId, setFilterServerId] = useState(ALL_SERVERS_ID) - +function OspfRoute() { + const { mode, backendUrl } = useDataSource() + const params = useSearchParams() + const state = readOspfUrl(new URLSearchParams(params.toString())) + function navigate(values: Record) { + const href = patchOspfUrl(new URLSearchParams(window.location.search), values) + if (href !== window.location.pathname + window.location.search) window.history.pushState(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 })} /> +} +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 }) { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" @@ -1130,15 +1134,12 @@ function OspfPageContent() { const [fetchTick, setFetchTick] = useState(0) const [readReceipts, setReadReceipts] = useState>({}) const [writeReceipt, setWriteReceipt] = useState<{ serverId: number; at: string } | null>(null) + const pageMounted = useRef(true) + useEffect(() => { pageMounted.current = true; return () => { pageMounted.current = false } }, []) const operationBusy = useRef(false) const [writeBusy, setWriteBusy] = useState(false) function refresh() { if (operationBusy.current) return; setLoading(true); setFetchTick(t => t + 1) } - // Reset server filter when switching live ↔ mock - useEffect(() => { - queueMicrotask(() => setFilterServerId("all")) - }, [isLive]) - useEffect(() => { if (!isLive) { queueMicrotask(() => setLiveData(null)) @@ -1172,8 +1173,8 @@ function OspfPageContent() { }, [isLive, backendUrl, fetchTick]) const unknownIds = useMemo(() => new Set(liveError ? liveServers.map(s => s.id) : (liveData?.failures ?? []).map(f => f.serverId)), [liveError, liveServers, liveData]) - const incomplete = isLive && (!liveData || Boolean(liveError) || (filterServerId === ALL_SERVERS_ID ? unknownIds.size > 0 : unknownIds.has(Number(filterServerId)))) - const dataReady = !isLive || Boolean(liveData && !liveError && !loading && !writeBusy && !liveData.failures.length && liveServers.some(s => String(s.id) === filterServerId) && can("mm:servers:write") && liveData.interfaces.filter(row => String(row.serverId) === filterServerId).every(row => row.cost !== null && row.cost >= 1 && row.disabled !== null && row.inactive !== null)) + const incomplete = Boolean(contextError) || isLive && (!liveData || Boolean(liveError) || (filterServerId === ALL_SERVERS_ID ? unknownIds.size > 0 : unknownIds.has(Number(filterServerId)))) + const dataReady = !contextError && (!isLive || Boolean(liveData && !liveError && !loading && !writeBusy && !liveData.failures.length && liveServers.some(s => String(s.id) === filterServerId) && can("mm:servers:write") && liveData.interfaces.filter(row => String(row.serverId) === filterServerId).every(row => row.cost !== null && row.cost >= 1 && row.disabled !== null && row.inactive !== null))) // Derive frontend types from backend data or use mocks const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => { @@ -1246,10 +1247,13 @@ function OspfPageContent() { }, [items, neighbors, bfdSessions]) // ── filtered display data ───────────────────────────────────────────────────── - const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId) - const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId) - const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId) - const displayRoutes = filterServerId === ALL_SERVERS_ID ? routes : routes.filter(r => r.serverId === filterServerId) + 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 ospfRailItems = useMemo(() => ( ospfServers.map((s) => { @@ -1280,7 +1284,7 @@ function OspfPageContent() { preserveSelection items={ospfRailItems} selectedId={filterServerId} - onSelect={id => { if (!operationBusy.current) setFilterServerId(id) }} + onSelect={id => { if (!operationBusy.current) onSelectServer(id) }} showAll allCount={ospfServers.length} loading={isLive && loading && ospfServers.length === 0} @@ -1302,7 +1306,7 @@ function OspfPageContent() {
{TABS.map(t => ( - } {/* data source banner */} {isLive && loading && (
@@ -1330,7 +1335,7 @@ function OspfPageContent() { {writeReceipt && (filterServerId === ALL_SERVERS_ID || String(writeReceipt.serverId) === filterServerId) &&

API подтвердил оптимизацию устройства {writeReceipt.serverId}:

}
} {isLive && (liveError || Boolean(liveData?.failures.length)) && {liveError ? "Не удалось загрузить OSPF" : "Не удалось опросить часть роутеров OSPF"}{liveError ?? liveData?.failures.map(f => f.serverName + ": " + f.error).join("; ")}. {liveData ? "Показаны последние полученные данные; состояния отказавших устройств неизвестны." : "Данные пока неизвестны."}} - {incomplete && (activeTab === "interfaces" ? displayItems.length === 0 : activeTab === "neighbors" ? displayNeighbors.length === 0 : activeTab === "bfd" ? displayBfdSessions.length === 0 : displayRoutes.length === 0) &&

Данные выбранного раздела OSPF пока неизвестны.

} + {viewIncomplete && (activeTab === "interfaces" ? displayItems.length === 0 : activeTab === "neighbors" ? displayNeighbors.length === 0 : activeTab === "bfd" ? displayBfdSessions.length === 0 : displayRoutes.length === 0) &&

Данные выбранного раздела OSPF пока неизвестны.

} {mode === "mock" && ( Моковые данные @@ -1343,54 +1348,57 @@ function OspfPageContent() { { id: "routers", label: "Роутеров", - value: incomplete ? "—" : totalRouters, - hint: incomplete ? "неизвестно" : undefined, + value: viewIncomplete || unavailable ? "—" : totalRouters, + hint: viewIncomplete || unavailable ? "неизвестно" : undefined, icon: , iconClassName: "text-muted-foreground", }, { id: "ifaces", label: "Интерфейсов", - value: incomplete ? "—" : totalInterfaces, + value: viewIncomplete ? "—" : totalInterfaces, icon: , iconClassName: "text-info", }, { id: "areas", label: "Зон (Area)", - value: incomplete ? "—" : totalAreas, + value: viewIncomplete ? "—" : totalAreas, icon: , iconClassName: "text-primary", }, ]} /> - {activeTab === "interfaces" && (!incomplete || displayItems.length > 0) && ( + {activeTab === "interfaces" && (!viewIncomplete || displayItems.length > 0) && ( { setLoading(true); setFetchTick(t => t + 1) }} dataReady={dataReady} - dataIncomplete={incomplete} - onOperationConfirmed={serverId => setWriteReceipt({ serverId, at: new Date().toISOString() })} - onOperationBusy={busy => { operationBusy.current = busy; setWriteBusy(busy) }} + dataIncomplete={viewIncomplete || unavailable} + onOperationConfirmed={serverId => { if (pageMounted.current) setWriteReceipt({ serverId, at: new Date().toISOString() }) }} + onOperationBusy={busy => { + if (!pageMounted.current) return + operationBusy.current = busy; setWriteBusy(busy) + if (!busy) { setLoading(true); setFetchTick(t => t + 1) } + }} /> )} - {activeTab === "neighbors" && (!incomplete || displayNeighbors.length > 0) && ( + {activeTab === "neighbors" && (!viewIncomplete || displayNeighbors.length > 0) && ( 0)} + dataIncomplete={viewIncomplete} + graphUnknown={unavailable || isLive && (!liveData || Boolean(liveError) || unknownIds.size > 0)} /> )} - {activeTab === "routes" && (!incomplete || displayRoutes.length > 0) && } - {activeTab === "bfd" && (!incomplete || displayBfdSessions.length > 0) && } + {activeTab === "routes" && (!viewIncomplete || displayRoutes.length > 0) && } + {activeTab === "bfd" && (!viewIncomplete || displayBfdSessions.length > 0) && }
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 45f44d3c..3b0e5faa 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.19, nullable интерфейсы/метрики OSPF; далее URL/layout | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.20, URL устройства/вкладки OSPF; далее поиск/layout | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.20 — URL-контекст и завершение OSPF-записи после history (2026-09-30) + +- Устройство/вкладка контролируются URL; reload/back восстанавливают выбор, другие параметры сохранены. Повторные/невалидные параметры и отсутствующее устройство дают явный alert, не раскрывают общий каталог и не показывают нулевую сводку; reset исправляет только этот контекст. +- После history во время POST новая форма остаётся заблокированной до завершения прежней операции и повторного опроса. Завершение всегда освобождает lock живого родителя; после смены источника/unmount callback игнорируется. Поздний результат не выдаёт toast в новой форме, подтверждённый receipt привязан к исходному устройству. +- Проверки: 17 URL/operations/freshness/interface-values E2E, восемь source read/write E2E и два unit прошли. TS чистый, lint один старый warning; первый unit-запуск дал системный ENOMEM до теста, повторный прошёл. Next Suspense добавлен для URL-контекста; production проверить после следующего блока. +- Далее поиск/контекст выбранного объекта, затем desktop/mobile и клавиатура. Использовано 10% окна; локальный коммит без push. + ## Блок 10.19 — интерфейсы и метрики строгого OSPF-каталога (2026-09-30) - Interface cost/priority/timers/type/disabled/inactive/useBfd и instance version/routerId/flags допускают null без legacy defaults. Оптимизация запрещена при неизвестных costs/состояниях выбранного устройства. DTO frontend выводятся из Zod, вместо дублирующих интерфейсов. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 9a59d22a..7c6ea183 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -128,3 +128,7 @@ Lock/readiness, существующее mm:servers:write, валидация п ## 10.19. Nullable интерфейсы и метрики Строгий catalog DTO сохраняет неизвестные поля interface/instance; route metric не подменяется distance. Наблюдаемый interface cost=0 сохранён согласно [RouterOS OSPF](https://help.mikrotik.com/docs/spaces/ROS/pages/331612216/routing%2Bospf); политика записи min=1 прежняя. При unknown costs/flags операция не предлагается. 21 E2E, 4 unit, backend regression и обе production-сборки прошли. Далее URL/layout и дополнительная проверка метаданных. + +## 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. diff --git a/lib/ospf-url-state.test.ts b/lib/ospf-url-state.test.ts new file mode 100644 index 00000000..a5e29278 --- /dev/null +++ b/lib/ospf-url-state.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict" +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.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) +}) diff --git a/lib/ospf-url-state.ts b/lib/ospf-url-state.ts new file mode 100644 index 00000000..f31d946c --- /dev/null +++ b/lib/ospf-url-state.ts @@ -0,0 +1,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 rawTab = params.get("tab") ?? "interfaces" + let error: string | null = null + if (["server", "tab"].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 } +} +export function patchOspfUrl(params: URLSearchParams, changes: Record) { + const next = new URLSearchParams(params) + for (const [key,value] of Object.entries(changes)) { if (value === null || value === "") next.delete(key); else next.set(key,value) } + return "/ospf" + (next.size ? "?" + next.toString() : "") +} diff --git a/tests/e2e/ospf-context.spec.ts b/tests/e2e/ospf-context.spec.ts index b37b1036..abf5a6b7 100644 --- a/tests/e2e/ospf-context.spec.ts +++ b/tests/e2e/ospf-context.spec.ts @@ -30,6 +30,7 @@ for (const change of ["backend", "mode"]) for (const operation of ["read", "writ await page.getByRole("button", { name: operation === "read" ? "Обновить" : "Оптимизировать OSPF", exact: true }).click() await expect.poll(() => started).toBe(true) await page.goBack() + await page.goBack() await expect(page).toHaveURL(/settings/) if (change === "backend") { const input = page.getByPlaceholder("http://localhost:8000") diff --git a/tests/e2e/ospf-links.spec.ts b/tests/e2e/ospf-links.spec.ts new file mode 100644 index 00000000..5ee15997 --- /dev/null +++ b/tests/e2e/ospf-links.spec.ts @@ -0,0 +1,38 @@ +import { test, expect, type Page } from "@playwright/test" +const host=(id:number)=>({serverId:id,serverName:"Роутер "+id,serverSite:"",serverCountry:"RU"}) +const iface=(id:number)=>({...host(id),id:"*1",instance:"default",area:"backbone",areaId:"0.0.0.0",interface:"ether"+id,cost:20,type:"broadcast",disabled:false,inactive:false,priority:1,helloInterval:10,deadInterval:40,useBfd:false}) +const catalog={interfaces:[iface(1),iface(2)],neighbors:[],instances:[],bfdSessions:[],routes:[],failures:[]} +async function setup(page: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/ospf/catalog"?catalog:path==="/api/servers"?[1,2].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,country:"RU",enabled:true,status:null})):path==="/api/auth/config"?{required:false}:{}})})} +test("OSPF: устройство и вкладка переживают reload/back",async({page})=>{ + await setup(page);await page.goto("/ospf?server=2&tab=bfd&keep=x") + await expect(page.getByRole("button",{name:"BFD",exact:true})).toHaveAttribute("aria-pressed","true") + await expect(page.getByRole("option").filter({hasText:"Роутер 2"})).toHaveAttribute("aria-selected","true") + await page.getByRole("button",{name:"Интерфейсы",exact:true}).click() + await expect(page.getByText("ether2",{exact:true})).toBeVisible();await expect(page.getByText("ether1",{exact:true})).toHaveCount(0) + await page.reload();await expect(page.getByText("ether2",{exact:true})).toBeVisible() + await page.getByRole("option").filter({hasText:"Роутер 1"}).click() + await expect(page).toHaveURL(/server=1/);await expect(page).toHaveURL(/keep=x/) + await page.goBack();await expect(page.getByText("ether2",{exact:true})).toBeVisible() + await page.goBack();await expect(page.getByRole("button",{name:"BFD",exact:true})).toHaveAttribute("aria-pressed","true") +}) +for(const context of ["server=999","tab=broken","server=1&server=2","tab=bfd&tab=routes"])test("OSPF: недоступный URL не показывает общий каталог "+context,async({page})=>{ + await setup(page);await page.goto("/ospf?"+context+"&keep=x") + const alert=page.getByRole("alert").filter({hasText:"Контекст OSPF недоступен"});await expect(alert).toBeVisible() + await expect(page.getByText("ether1",{exact:true})).toHaveCount(0) + await expect(page.getByLabel("Сводка OSPF",{exact:true})).toContainText("неизвестно") + await alert.getByRole("button",{name:"Сбросить контекст OSPF"}).click() + await expect(page).toHaveURL(/keep=x/);await expect(alert).toHaveCount(0);await expect(page.getByText("ether1",{exact:true})).toBeVisible() +}) +for(const fails of [false,true])test("OSPF: history во время записи освобождает lock "+fails,async({page})=>{ + await setup(page);let writes=0,started=false,reads=0;let release!:()=>void;const gate=new Promise(resolve=>{release=resolve}) + await page.route("**/api/ospf/catalog",r=>{reads++;return r.fulfill({json:catalog})}) + await page.route("**/api/servers/1/ospf/optimize",async r=>{writes++;started=true;await gate;return r.fulfill(fails?{status:503,json:{error:"Старая ошибка"}}:{json:{serverId:1,serverName:"Роутер 1",pingWeight:60,optimizedCount:0,applied:[],interfaces:[]}})}) + await page.goto("/ospf");await page.getByRole("option").filter({hasText:"Роутер 1"}).click() + await page.getByRole("button",{name:"Оптимизировать OSPF",exact:true}).click();await expect.poll(()=>started).toBe(true) + await page.goBack();await expect(page).not.toHaveURL(/server=1/) + await page.goForward();const optimize=page.getByRole("button",{name:"Оптимизировать OSPF",exact:true});await expect(optimize).toBeDisabled() + const before=reads;const response=page.waitForResponse(r=>r.url().includes("/api/servers/1/ospf/optimize"));release();await (await response).finished() + await expect.poll(()=>reads).toBe(before+1);await expect(optimize).toBeEnabled();expect(writes).toBe(1) + await expect(page.getByText("Старая ошибка",{exact:false})).toHaveCount(0) + await expect(page.locator("[data-sonner-toast]").filter({hasText:"API подтвердил оптимизацию OSPF"})).toHaveCount(0) +})