diff --git a/app/(main)/route-optimizer/page.tsx b/app/(main)/route-optimizer/page.tsx index aef4c875..b2fd974e 100644 --- a/app/(main)/route-optimizer/page.tsx +++ b/app/(main)/route-optimizer/page.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useState, useMemo, useRef } from "react" import Link from "next/link" -import {optimizerServersSchema,optimizerRulesSchema,optimizerProbesSchema} from "@/lib/route-optimizer-response" +import {ospfCatalogSchema} from "@/lib/ospf-response" +import {optimizerServersSchema,optimizerRulesSchema,optimizerProbesSchema,optimizerOspfPreviewSchema,type OptimizerOspfPreview} from "@/lib/route-optimizer-response" import { PageHeader } from "@/components/page-header" import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid" import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-optimizer-full-routes-data-grid" @@ -451,30 +452,16 @@ function RouteOptimizerContent() { const [ospfApplying, setOspfApplying] = useState(false) const [ospfApplyResult, setOspfApplyResult] = useState<{ serverName: string; optimizedCount: number } | null>(null) const [ospfApplyError, setOspfApplyError] = useState("") - const [ospfMeta, setOspfMeta] = useState<{ interfaces: number; areas: number; serverName: string } | null>(null) - const [ospfPreviewError, setOspfPreviewError] = useState("") - const [ospfPreview, setOspfPreview] = useState<{ - changedCount: number - interfacesTotal: number - interfaces: Array<{ - interface: string - currentCost: number - optimalCost: number - score: number - pingMs: number - dlMbps: number - ulMbps: number - }> - changes: Array<{ - interface: string - currentCost: number - optimalCost: number - score: number - pingMs: number - dlMbps: number - ulMbps: number - }> - } | null>(null) + const [metaState,setMetaState]=useState<{owner:string;data:{interfaces:number;areas:number;serverName:string}|null;error:string}>({owner:"",data:null,error:""}) + const [previewState,setPreviewState]=useState<{owner:string;data:OptimizerOspfPreview|null;error:string;pending:boolean}>({owner:"",data:null,error:"",pending:false}) + const [previewTick,setPreviewTick]=useState(0) + const previewKey=JSON.stringify([ospfServerId,settings.pingWeight]) + const ospfMeta=metaState.owner===ospfServerId?metaState.data:null + const ospfMetaError=metaState.owner===ospfServerId?metaState.error:"" + const ospfPreview=previewState.owner===previewKey?previewState.data:null + const ospfPreviewError=previewState.owner===previewKey?previewState.error:"" + const ospfPreviewLoading=useLiveData && Boolean(ospfServerId) && (previewState.owner!==previewKey || previewState.pending) + const previewReady=Boolean(ospfPreview) && !ospfPreviewLoading && !ospfPreviewError const load = useCallback(async (override?: OptimizerSettings) => { const epoch = ++reader.current.epoch @@ -621,7 +608,7 @@ function RouteOptimizerContent() { } async function applyOspfOptimization() { - if (!ospfServerId || loading || error || !data) return + if (!ospfServerId || loading || error || !data || !previewReady) return setOspfApplying(true) setOspfApplyError("") setOspfApplyResult(null) @@ -638,7 +625,7 @@ function RouteOptimizerContent() { optimizedCount: res.optimizedCount, }) await load() - setOspfPreview((prev) => (prev ? { ...prev, changedCount: 0, changes: [] } : prev)) + setPreviewTick(value=>value+1) } catch (e) { setOspfApplyError(e instanceof Error ? e.message : "Ошибка применения OSPF-оптимизации") } finally { @@ -649,74 +636,42 @@ function RouteOptimizerContent() { const set = (k: K, v: OptimizerSettings[K]) => setSettings(prev => ({ ...prev, [k]: v })) - useEffect(() => { - if (!useLiveData || !ospfServerId) return - let cancelled = false - void apiFetch<{ - interfaces: Array<{ areaId: string; interface: string }> - instances: Array - neighbors: Array - bfdSessions: Array - }>(`/api/servers/${ospfServerId}/ospf`) - .then((data) => { - if (cancelled) return - const selected = liveServers.find((s) => String(s.id) === ospfServerId) - const visibleInterfaces = data.interfaces.filter((i) => !/^\(ref\s+\*.+\)$/.test(i.interface.trim())) - setOspfMeta({ - interfaces: visibleInterfaces.length, - areas: new Set(visibleInterfaces.map((i) => i.areaId)).size, - serverName: selected?.name || selected?.host || ospfServerId, - }) - }) - .catch(() => { - if (cancelled) return - setOspfMeta(null) - }) - return () => { cancelled = true } - }, [apiFetch, liveServers, ospfServerId, useLiveData]) + useEffect(()=>{ + if(!useLiveData||!ospfServerId)return + let cancelled=false + void apiFetch("/api/ospf/catalog").then(raw=>{ + if(cancelled)return + const parsed=ospfCatalogSchema.safeParse(raw) + if(!parsed.success)throw new Error("Недостоверный каталог OSPF") + const catalog=parsed.data + const failure=catalog.failures.find(item=>String(item.serverId)===ospfServerId) + if(failure)throw new Error(failure.error) + const interfaces=catalog.interfaces.filter(item=>String(item.serverId)===ospfServerId && !/^\(ref\s+\*.+\)$/.test(item.interface.trim())) + const selected=liveServers.find(item=>String(item.id)===ospfServerId) + setMetaState({owner:ospfServerId,data:{interfaces:interfaces.length,areas:new Set(interfaces.map(item=>item.areaId)).size,serverName:selected?.name||selected?.host||ospfServerId},error:""}) + }).catch(error=>{ + if(!cancelled)setMetaState(previous=>({owner:ospfServerId,data:previous.owner===ospfServerId?previous.data:null,error:error instanceof Error?error.message:"Ошибка каталога OSPF"})) + }) + return()=>{cancelled=true} + },[apiFetch,liveServers,ospfServerId,useLiveData,previewTick]) - useEffect(() => { - if (!useLiveData || !ospfServerId) return - let cancelled = false - void apiFetch<{ - changedCount: number - interfacesTotal: number - interfaces: Array<{ - interface: string - currentCost: number - optimalCost: number - score: number - pingMs: number - dlMbps: number - ulMbps: number - }> - changes: Array<{ - interface: string - currentCost: number - optimalCost: number - score: number - pingMs: number - dlMbps: number - ulMbps: number - }> - }>( - `/api/servers/${ospfServerId}/ospf/optimize/preview`, - { method: "POST", body: JSON.stringify({ pingWeight: settings.pingWeight }) }, - ) - .then((data) => { - if (cancelled) return - setOspfPreviewError("") - setOspfPreview(data) + useEffect(()=>{ + if(!useLiveData||!ospfServerId)return + let cancelled=false + queueMicrotask(()=>{ + if(cancelled)return + setPreviewState(previous=>({owner:previewKey,data:previous.owner===previewKey?previous.data:null,error:"",pending:true})) + void apiFetch("/api/servers/"+ospfServerId+"/ospf/optimize/preview",{method:"POST",body:JSON.stringify({pingWeight:settings.pingWeight})}).then(raw=>{ + if(cancelled)return + const preview=optimizerOspfPreviewSchema.safeParse(raw) + if(!preview.success||String(preview.data.serverId)!==ospfServerId||preview.data.pingWeight!==settings.pingWeight)throw new Error("Недостоверный preview OSPF или другой контекст") + setPreviewState({owner:previewKey,data:preview.data,error:"",pending:false}) + }).catch(error=>{ + if(!cancelled)setPreviewState(previous=>({owner:previewKey,data:previous.owner===previewKey?previous.data:null,error:error instanceof Error?error.message:"Ошибка preview OSPF",pending:false})) }) - .catch((e) => { - if (cancelled) return - setOspfPreview(null) - setOspfPreviewError(e instanceof Error ? e.message : "Ошибка preview OSPF") - }) - return () => { cancelled = true } - }, [apiFetch, ospfServerId, settings.pingWeight, useLiveData]) - - const ospfPreviewLoading = useLiveData && Boolean(ospfServerId) && !ospfPreview && !ospfPreviewError + }) + return()=>{cancelled=true} + },[apiFetch,ospfServerId,settings.pingWeight,useLiveData,previewKey,readAt,previewTick]) return (
@@ -862,6 +817,7 @@ function RouteOptimizerContent() { + {ospfMetaError &&

Каталог OSPF: {ospfMetaError}. Актуальность неизвестна.

} {/* OSPF optimization from Route Optimizer */} - {ospfMeta ? `${ospfMeta.interfaces} iface · ${ospfMeta.areas} area` : "сбор OSPF-метрик…"} + {ospfMetaError ? "Каталог OSPF недостоверен" : ospfMeta ? `${ospfMeta.interfaces} iface · ${ospfMeta.areas} area` : "сбор OSPF-метрик…"} {ospfPreviewLoading ? "расчёт…" @@ -926,10 +883,7 @@ function RouteOptimizerContent() {
({ - id: `${row.interface}-${row.currentCost}-${row.optimalCost}`, - ...row, - }))} + rows={ospfPreview?.interfaces ?? []} error={ospfPreviewError || null} loading={ospfPreviewLoading} /> diff --git a/components/data-grids/route-optimizer-ospf-preview-data-grid.tsx b/components/data-grids/route-optimizer-ospf-preview-data-grid.tsx index 3869d7cc..45d3befb 100644 --- a/components/data-grids/route-optimizer-ospf-preview-data-grid.tsx +++ b/components/data-grids/route-optimizer-ospf-preview-data-grid.tsx @@ -118,9 +118,9 @@ function RouteOptimizerOspfPreviewDataGrid({ getRowId: (row) => row.id, }) - if (error) { + if (error && rows.length === 0) { return ( -

Ошибка preview: {error}

+

Ошибка preview: {error}

) } @@ -135,14 +135,18 @@ function RouteOptimizerOspfPreviewDataGrid({ } return ( + <> + {error &&

Ошибка preview: {error}. Последний снимок; актуальность неизвестна.

} + {loading && rows.length > 0 &&

Обновление preview OSPF…

} + ) } diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 326cbd6f..8cd9cf4a 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,19 @@ | 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.30; согласованное чтение оптимизатора, далее OSPF preview/apply | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.31; строгий OSPF preview оптимизатора, далее подтверждение apply | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.31 — OSPF preview в оптимизаторе (2026-09-30) + +- Preview требует точных serverId/pingWeight, идентификаторов интерфейсов, уникальности и согласованных interfaces/changes/counts. Старый или чужой контекст не публикуется и блокирует apply. +- Metadata переходит на строгий /ospf/catalog; ошибки выбранного устройства отображаются. Preview имеет явный повтор и обновляется после свежего расчёта. Pending/ошибка сохраняют последний preview того же контекста, с предупреждением; другой контекст его скрывает. Таблица использует реальные id. +- Optimistic обнуление changedCount после apply убрано: результат должен перечитываться. Сам receipt apply пока legacy — следующий отдельный блок. Backend алгоритм не менялся. +- Проверки: 12 E2E (шесть invalid/HTTP preview, повтор, cache, late device и четыре таблицы), TS/lint прошли; ранее 16 источников/доверия прошли после обновления контрактных fixtures. В сумме проверено 24 разных сценария этой группы. +- Далее 10.32: strict OSPF receipt, synchronous lock/права/контекст, ошибки частичного apply и reread. Затем backend provenance планировщика и visual acceptance; реальная RouterOS оптимизация не проверена. Последняя квота: 74% окна, 43% недели использовано. Локальный коммит без push. + ## Блок 10.30 — согласованное чтение оптимизатора (2026-09-30) - Inventory/rules/probes читаются параллельно и валидируются до публикации полного расчёта. Неизвестные типы, дубликаты, stale правила, плохие измерения и чужие ссылки на устройства отклоняются; missing payload не заменяется пустым массивом. Stored rules с live:false/stale:false допустимы как конфигурация БД для модели. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 4fba8eef..696adde0 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -172,3 +172,7 @@ Live таймерный успех убран; demo симуляция явно ## 10.30. Согласованные источники расчёта Строгие inventory/rules/probes, Promise.allSettled и публикация только полного valid snapshot. Partial сбой сохраняет предыдущее; initial неизвестность без нулей; epoch/freshness. 16 E2E + 2 unit, lint/production frontend прошли. Далее OSPF preview/apply; модельные fallback и metadata legacy пока явно ограничены. + +## 10.31. Строгий preview OSPF + +Контекст serverId/веса, полный список изменений/counts/ids; metadata strict catalog, повтор/last preview/late context. 12 профильных + ранее 16 существующих (24 разных), TS/lint прошли. Apply receipt и происхождение backend модельных значений ещё требуют проверки; далее 10.32. diff --git a/lib/route-optimizer-response.ts b/lib/route-optimizer-response.ts index 2e412528..04d6947d 100644 --- a/lib/route-optimizer-response.ts +++ b/lib/route-optimizer-response.ts @@ -1,4 +1,5 @@ import {z} from "zod" +import {ospfOptimizeSchema} from "./ospf-response" import {filtersResponseSchema} from "./filters-response" const unique = (rows:T[],key:(row:T)=>string|number)=>new Set(rows.map(key)).size===rows.length const measured = z.number().finite().nonnegative().nullable() @@ -15,3 +16,16 @@ export const optimizerProbesSchema=z.object({probes:z.array(z.object({ lastPingRttMs:measured,lastPingLossPct:z.number().finite().min(0).max(100).nullable(), lastTxAvgMbps:measured,lastRxAvgMbps:measured,lastPingAt:z.string().datetime({offset:true}).nullable(), })).refine(rows=>unique(rows,row=>row.id))}) + +export const optimizerOspfPreviewSchema=z.object({ + serverId:z.number().int().positive(),serverName:z.string(),pingWeight:z.number().int().min(0).max(100), + interfacesTotal:z.number().int().nonnegative(),changedCount:z.number().int().nonnegative(),unchangedCount:z.number().int().nonnegative(), + interfaces:ospfOptimizeSchema.shape.interfaces,changes:ospfOptimizeSchema.shape.interfaces, +}).refine(data=>{ + const changed=data.interfaces.filter(row=>row.currentCost!==row.optimalCost) + return data.interfacesTotal===data.interfaces.length && data.changedCount===data.changes.length && data.unchangedCount===data.interfacesTotal-data.changedCount + && new Set(data.interfaces.map(row=>row.id)).size===data.interfaces.length + && new Set(data.changes.map(row=>row.id)).size===data.changes.length + && changed.length===data.changes.length && changed.every(row=>data.changes.some(change=>JSON.stringify(change)===JSON.stringify(row))) +}) +export type OptimizerOspfPreview=z.infer diff --git a/tests/e2e/route-optimizer-data.spec.ts b/tests/e2e/route-optimizer-data.spec.ts index 064019d7..453ca857 100644 --- a/tests/e2e/route-optimizer-data.spec.ts +++ b/tests/e2e/route-optimizer-data.spec.ts @@ -1,7 +1,7 @@ import {test,expect,type Page} from "@playwright/test" const hosts=(name="Роутер")=>(["home-router","jump-host","exit-node"] as const).map((type,index)=>({id:index+1,name:name+" "+(index+1),host:"192.0.2."+(index+1),type,site:"TEST",country:"RU",enabled:true,status:"online",latency:10,model:"CHR",...(index===0?{wanUplinks:[{id:"wan",name:"WAN",isp:"ISP",iface:"ether1",ip:"198.51.100.1",maxDl:100,maxUl:100}]}:{})})) const paths={servers:"/api/servers",rules:"/api/filters/rules",probes:"/api/uptime/speed-probes"} as const -const body=(p:string,name="Роутер")=>p===paths.servers?hosts(name):p===paths.rules?{rulesets:[{serverId:"1",rules:[{id:"r",community:"65001:1",communityName:"Сервис",gateway:"192.0.2.2",gatewayTunnelId:"",description:""}]}]}:p===paths.probes?{probes:[]}:p==="/api/auth/config"?{required:false}:p.endsWith("/preview")?{interfaces:[],changes:[],changedCount:0,interfacesTotal:0}:p.endsWith("/ospf")?{interfaces:[],instances:[],neighbors:[],bfdSessions:[]}:{} +const body=(p:string,name="Роутер")=>p===paths.servers?hosts(name):p===paths.rules?{rulesets:[{serverId:"1",rules:[{id:"r",community:"65001:1",communityName:"Сервис",gateway:"192.0.2.2",gatewayTunnelId:"",description:""}]}]}:p===paths.probes?{probes:[]}:p==="/api/auth/config"?{required:false}:p.endsWith("/preview")?{serverId:1,serverName:"Роутер 1",pingWeight:60,unchangedCount:0,interfaces:[],changes:[],changedCount:0,interfacesTotal:0}:p==="/api/ospf/catalog"?{interfaces:[],instances:[],neighbors:[],bfdSessions:[],routes:[],failures:[]}:{} async function health(page:Page){await page.route("**/health",r=>r.fulfill({json:{ok:true}}))} for(const part of ["servers","rules","probes"] as const)for(const malformed of [false,true])test("Оптимизатор: первоначальная ошибка и повтор "+part+"/"+malformed,async({page})=>{ let failing=true;await health(page) diff --git a/tests/e2e/route-optimizer-ospf-preview.spec.ts b/tests/e2e/route-optimizer-ospf-preview.spec.ts new file mode 100644 index 00000000..dd4bbb59 --- /dev/null +++ b/tests/e2e/route-optimizer-ospf-preview.spec.ts @@ -0,0 +1,39 @@ +import {test,expect,type Page} from "@playwright/test" +const iface=(name="ether1")=>({id:"i",interface:name,currentCost:10,optimalCost:20,score:50,pingMs:10,dlMbps:100,ulMbps:50}) +const preview=(id=1,weight=60,name="ether1")=>({serverId:id,serverName:"Роутер "+id,pingWeight:weight,interfacesTotal:1,changedCount:1,unchangedCount:0,interfaces:[iface(name)],changes:[iface(name)]}) +async function base(page:Page){ + await page.route("**/health",r=>r.fulfill({json:{ok:true}})) + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[1,2].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,type:"home-router",country:"RU",enabled:true,status:null})):p==="/api/auth/config"?{required:false}:p==="/api/filters/rules"?{rulesets:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/ospf/catalog"?{interfaces:[],instances:[],neighbors:[],bfdSessions:[],routes:[],failures:[]}:preview(Number(p.split("/")[3]),r.request().postDataJSON()?.pingWeight??60)})}) +} +for(const failure of ["http","foreign","weight","count","missing","duplicate"] as const)test("Оптимизатор: OSPF preview блокирует "+failure,async({page})=>{ + await base(page);let failing=true + await page.route("**/ospf/optimize/preview",r=>{ + const good=preview(),bad=failure==="foreign"?{...good,serverId:2}:failure==="weight"?{...good,pingWeight:20}:failure==="count"?{...good,changedCount:0}:failure==="duplicate"?{...good,interfaces:[iface(),iface()],interfacesTotal:2}:{} + return r.fulfill(failing?(failure==="http"?{status:503,json:{error:"Preview недоступен"}}:{json:bad}):{json:good}) + }) + await page.goto("/route-optimizer") + await expect(page.getByRole("alert").filter({hasText:"Ошибка preview"})).toBeVisible() + const apply=page.getByRole("button",{name:"Оптимизировать OSPF",exact:true});await expect(apply).toBeDisabled() + await expect(page.getByRole("table").filter({hasText:"Speed (dl/ul)"})).toHaveCount(0) + failing=false;await page.getByRole("button",{name:"Обновить preview OSPF",exact:true}).click() + await expect(page.getByRole("table").filter({hasText:"Speed (dl/ul)"}).locator("tr[data-row-id]")).toHaveCount(1) + await expect(apply).toBeEnabled() +}) +test("Оптимизатор: preview сохраняется при фоновом отказе",async({page})=>{ + await base(page);let failing=false + await page.route("**/ospf/optimize/preview",r=>r.fulfill(failing?{status:503,json:{error:"Фоновый отказ"}}:{json:preview()})) + await page.goto("/route-optimizer");const grid=page.getByRole("table").filter({hasText:"Speed (dl/ul)"});await expect(grid.locator("tr[data-row-id]")).toHaveCount(1) + failing=true;await page.getByRole("button",{name:"Обновить preview OSPF",exact:true}).click() + await expect(page.getByRole("alert").filter({hasText:"Последний снимок"})).toBeVisible();await expect(grid.locator("tr[data-row-id]")).toHaveCount(1) + await expect(page.getByRole("button",{name:"Оптимизировать OSPF",exact:true})).toBeDisabled() +}) +test("Оптимизатор: поздний preview прежнего устройства игнорируется",async({page})=>{ + await base(page);let started=false,release!:()=>void;const gate=new Promise(resolve=>{release=resolve}) + await page.route("**/ospf/optimize/preview",async r=>{const id=Number(new URL(r.request().url()).pathname.split("/")[3]);if(id===1){started=true;await gate}return r.fulfill({json:preview(id,60,"ether"+id)})}) + await page.goto("/route-optimizer");await expect.poll(()=>started).toBe(true) + await page.locator("select").selectOption("2") + const grid=page.getByRole("table").filter({hasText:"Speed (dl/ul)"});await expect(grid).toContainText("ether2") + const response=page.waitForResponse(r=>new URL(r.url()).pathname==="/api/servers/1/ospf/optimize/preview");release();await(await response).finished() + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve())))) + await expect(grid).toContainText("ether2");await expect(grid).not.toContainText("ether1") +}) diff --git a/tests/e2e/route-optimizer-tables.spec.ts b/tests/e2e/route-optimizer-tables.spec.ts index a80f7591..9aefe660 100644 --- a/tests/e2e/route-optimizer-tables.spec.ts +++ b/tests/e2e/route-optimizer-tables.spec.ts @@ -14,15 +14,15 @@ async function setup(page: Page, preview: "ok" | "empty" | "error" = "ok") { const path = new URL(route.request().url()).pathname if (path.endsWith("/ospf/optimize/preview")) { if (preview === "error") return route.fulfill({ status: 500, json: { error: "Расчёт недоступен" } }) - const interfaces = preview === "empty" ? [] : ids.map(i => ({ interface: `ether${i}`, currentCost: i, optimalCost: 20, score: i, pingMs: i, dlMbps: 100, ulMbps: 50 })) - return route.fulfill({ json: { interfaces, changes: interfaces, changedCount: interfaces.length, interfacesTotal: interfaces.length } }) + const interfaces = preview === "empty" ? [] : ids.map(i => ({ id: String(i), interface: `ether${i}`, currentCost: i, optimalCost: 20, score: i, pingMs: i, dlMbps: 100, ulMbps: 50 })) + return route.fulfill({ json: {serverId:1,serverName:"Роутер 1",pingWeight:60,unchangedCount:0, interfaces, changes: interfaces, changedCount: interfaces.length, interfacesTotal: interfaces.length } }) } return route.fulfill({ json: path === "/api/servers" ? servers : path === "/api/auth/config" ? { required: false } : path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: ids.map(i => ({ id: String(i), community: `65001:${i}`, communityName: `Сервис ${i}`, gateway: "192.0.2.2", gatewayTunnelId: "", description: "" })) }] } : path === "/api/uptime/speed-probes" ? { probes: [] } - : path.endsWith("/ospf") ? { interfaces: [], instances: [], neighbors: [], bfdSessions: [] } : {} }) + : path === "/api/ospf/catalog" ? { interfaces: [], instances: [], neighbors: [], bfdSessions: [], routes: [], failures: [] } : {} }) }) await page.goto("/route-optimizer") await expect(page.getByRole("table").filter({ hasText: "WAN-аплинк" }).locator("tr[data-row-id]")).toHaveCount(12) diff --git a/tests/e2e/route-optimizer-trust.spec.ts b/tests/e2e/route-optimizer-trust.spec.ts index 6905831a..41c28970 100644 --- a/tests/e2e/route-optimizer-trust.spec.ts +++ b/tests/e2e/route-optimizer-trust.spec.ts @@ -6,7 +6,7 @@ test("Оптимизатор: live рекомендации не подтвер await page.route("**/api/**",r=>{ const p=new URL(r.request().url()).pathname if(r.request().method()!=="GET"&&!p.endsWith("/preview"))writes++ - return r.fulfill({json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?hosts:p==="/api/filters/rules"?{rulesets:[{serverId:"1",rules:[{id:"r",community:"65001:1",communityName:"Сервис",gateway:"192.0.2.2",gatewayTunnelId:"",description:""}]}]}:p==="/api/uptime/speed-probes"?{probes:[]}:p.endsWith("/preview")?{interfaces:[],changes:[],changedCount:0,interfacesTotal:0}:p.endsWith("/ospf")?{interfaces:[],instances:[],neighbors:[],bfdSessions:[]}:{}}) + return r.fulfill({json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?hosts:p==="/api/filters/rules"?{rulesets:[{serverId:"1",rules:[{id:"r",community:"65001:1",communityName:"Сервис",gateway:"192.0.2.2",gatewayTunnelId:"",description:""}]}]}:p==="/api/uptime/speed-probes"?{probes:[]}:p.endsWith("/preview")?{serverId:1,serverName:"Роутер 1",pingWeight:60,unchangedCount:0,interfaces:[],changes:[],changedCount:0,interfacesTotal:0}:p==="/api/ospf/catalog"?{interfaces:[],instances:[],neighbors:[],bfdSessions:[],routes:[],failures:[]}:{}}) }) await page.goto("/route-optimizer") await page.getByRole("button",{name:"BGP community (1)",exact:true}).click()