fix(ui): проверять контекст расчёта OSPF оптимизатора

This commit is contained in:
Denozordec committed 2026-09-30 22:47:04 +07:00
1 parent f93dfe34c2
commit bf2815b5a4
9 files changed
+131 -108

No files matched your search

+53 -99
View File
@@ -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 extends keyof OptimizerSettings>(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<unknown>
neighbors: Array<unknown>
bfdSessions: Array<unknown>
}>(`/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<unknown>("/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<unknown>("/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 (
<div className="flex flex-col h-full">
@@ -862,6 +817,7 @@ function RouteOptimizerContent() {
</FramePanel>
</Frame>
{ospfMetaError && <p role="alert" className="text-sm text-warning">Каталог OSPF: {ospfMetaError}. Актуальность неизвестна.</p>}
{/* OSPF optimization from Route Optimizer */}
<OpsPanel
title="OSPF"
@@ -892,13 +848,13 @@ function RouteOptimizerContent() {
))}
</select>
<span className="text-[11px] text-muted-foreground">
{ospfMeta ? `${ospfMeta.interfaces} iface · ${ospfMeta.areas} area` : "сбор OSPF-метрик…"}
{ospfMetaError ? "Каталог OSPF недостоверен" : ospfMeta ? `${ospfMeta.interfaces} iface · ${ospfMeta.areas} area` : "сбор OSPF-метрик…"}
</span>
<Button
size="sm"
className="ml-auto h-7 text-xs"
onClick={() => void applyOspfOptimization()}
disabled={loading || Boolean(error) || !data || !ospfServerId || ospfApplying}
disabled={loading || Boolean(error) || !data || !ospfServerId || ospfApplying || !previewReady || Boolean(ospfMetaError)}
>
<ZapIcon className={cn("size-3.5", ospfApplying && "animate-pulse")} />
{ospfApplying ? "Оптимизация…" : "Оптимизировать OSPF"}
@@ -917,6 +873,7 @@ function RouteOptimizerContent() {
<div className="rounded-lg border bg-background/70 overflow-hidden">
<div className="px-3 py-2 border-b text-[11px] text-muted-foreground flex items-center justify-between">
<span>Preview изменений OSPF cost (до применения)</span>
<Button type="button" variant="outline" size="sm" disabled={ospfApplying || ospfPreviewLoading} onClick={()=>setPreviewTick(value=>value+1)}>Обновить preview OSPF</Button>
<span>
{ospfPreviewLoading
? "расчёт…"
@@ -926,10 +883,7 @@ function RouteOptimizerContent() {
</span>
</div>
<RouteOptimizerOspfPreviewDataGrid
rows={(ospfPreview?.interfaces ?? []).map((row) => ({
id: `${row.interface}-${row.currentCost}-${row.optimalCost}`,
...row,
}))}
rows={ospfPreview?.interfaces ?? []}
error={ospfPreviewError || null}
loading={ospfPreviewLoading}
/>
@@ -118,9 +118,9 @@ function RouteOptimizerOspfPreviewDataGrid({
getRowId: (row) => row.id,
})
if (error) {
if (error && rows.length === 0) {
return (
<p className="px-3 py-2 text-xs text-destructive">Ошибка preview: {error}</p>
<p role="alert" className="px-3 py-2 text-xs text-destructive">Ошибка preview: {error}</p>
)
}
@@ -135,14 +135,18 @@ function RouteOptimizerOspfPreviewDataGrid({
}
return (
<>
{error && <p role="alert" className="px-3 py-2 text-xs text-warning">Ошибка preview: {error}. Последний снимок; актуальность неизвестна.</p>}
{loading && rows.length > 0 && <p role="status" className="px-3 py-2 text-xs text-muted-foreground">Обновление preview OSPF…</p>}
<DataGridShell
table={table}
recordCount={rows.length}
isLoading={loading}
isLoading={loading && rows.length === 0}
loadingMode="skeleton"
tableClassNames={{ headerRow: "border-b bg-muted/30", bodyRow: "group/row" }}
tableLayout={{ dense: true }}
/>
</>
)
}
+9 -1
View File
@@ -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 допустимы как конфигурация БД для модели.
+4
View File
@@ -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.
+14
View File
@@ -1,4 +1,5 @@
import {z} from "zod"
import {ospfOptimizeSchema} from "./ospf-response"
import {filtersResponseSchema} from "./filters-response"
const unique = <T,>(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<typeof optimizerOspfPreviewSchema>
+1 -1
View File
@@ -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)
@@ -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<void>(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<void>(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve()))))
await expect(grid).toContainText("ether2");await expect(grid).not.toContainText("ether1")
})
+3 -3
View File
@@ -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)
+1 -1
View File
@@ -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()