fix(ui): проверять контекст расчёта OSPF оптимизатора
This commit is contained in:
1 parent
f93dfe34c2
commit
bf2815b5a4
9 files changed
+131
-108
No files matched your search
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
||||||
import Link from "next/link"
|
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 { PageHeader } from "@/components/page-header"
|
||||||
import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid"
|
import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid"
|
||||||
import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-optimizer-full-routes-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 [ospfApplying, setOspfApplying] = useState(false)
|
||||||
const [ospfApplyResult, setOspfApplyResult] = useState<{ serverName: string; optimizedCount: number } | null>(null)
|
const [ospfApplyResult, setOspfApplyResult] = useState<{ serverName: string; optimizedCount: number } | null>(null)
|
||||||
const [ospfApplyError, setOspfApplyError] = useState("")
|
const [ospfApplyError, setOspfApplyError] = useState("")
|
||||||
const [ospfMeta, setOspfMeta] = useState<{ interfaces: number; areas: number; serverName: string } | null>(null)
|
const [metaState,setMetaState]=useState<{owner:string;data:{interfaces:number;areas:number;serverName:string}|null;error:string}>({owner:"",data:null,error:""})
|
||||||
const [ospfPreviewError, setOspfPreviewError] = useState("")
|
const [previewState,setPreviewState]=useState<{owner:string;data:OptimizerOspfPreview|null;error:string;pending:boolean}>({owner:"",data:null,error:"",pending:false})
|
||||||
const [ospfPreview, setOspfPreview] = useState<{
|
const [previewTick,setPreviewTick]=useState(0)
|
||||||
changedCount: number
|
const previewKey=JSON.stringify([ospfServerId,settings.pingWeight])
|
||||||
interfacesTotal: number
|
const ospfMeta=metaState.owner===ospfServerId?metaState.data:null
|
||||||
interfaces: Array<{
|
const ospfMetaError=metaState.owner===ospfServerId?metaState.error:""
|
||||||
interface: string
|
const ospfPreview=previewState.owner===previewKey?previewState.data:null
|
||||||
currentCost: number
|
const ospfPreviewError=previewState.owner===previewKey?previewState.error:""
|
||||||
optimalCost: number
|
const ospfPreviewLoading=useLiveData && Boolean(ospfServerId) && (previewState.owner!==previewKey || previewState.pending)
|
||||||
score: number
|
const previewReady=Boolean(ospfPreview) && !ospfPreviewLoading && !ospfPreviewError
|
||||||
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 load = useCallback(async (override?: OptimizerSettings) => {
|
const load = useCallback(async (override?: OptimizerSettings) => {
|
||||||
const epoch = ++reader.current.epoch
|
const epoch = ++reader.current.epoch
|
||||||
@@ -621,7 +608,7 @@ function RouteOptimizerContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyOspfOptimization() {
|
async function applyOspfOptimization() {
|
||||||
if (!ospfServerId || loading || error || !data) return
|
if (!ospfServerId || loading || error || !data || !previewReady) return
|
||||||
setOspfApplying(true)
|
setOspfApplying(true)
|
||||||
setOspfApplyError("")
|
setOspfApplyError("")
|
||||||
setOspfApplyResult(null)
|
setOspfApplyResult(null)
|
||||||
@@ -638,7 +625,7 @@ function RouteOptimizerContent() {
|
|||||||
optimizedCount: res.optimizedCount,
|
optimizedCount: res.optimizedCount,
|
||||||
})
|
})
|
||||||
await load()
|
await load()
|
||||||
setOspfPreview((prev) => (prev ? { ...prev, changedCount: 0, changes: [] } : prev))
|
setPreviewTick(value=>value+1)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setOspfApplyError(e instanceof Error ? e.message : "Ошибка применения OSPF-оптимизации")
|
setOspfApplyError(e instanceof Error ? e.message : "Ошибка применения OSPF-оптимизации")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -649,74 +636,42 @@ function RouteOptimizerContent() {
|
|||||||
const set = <K extends keyof OptimizerSettings>(k: K, v: OptimizerSettings[K]) =>
|
const set = <K extends keyof OptimizerSettings>(k: K, v: OptimizerSettings[K]) =>
|
||||||
setSettings(prev => ({ ...prev, [k]: v }))
|
setSettings(prev => ({ ...prev, [k]: v }))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(()=>{
|
||||||
if (!useLiveData || !ospfServerId) return
|
if(!useLiveData||!ospfServerId)return
|
||||||
let cancelled = false
|
let cancelled=false
|
||||||
void apiFetch<{
|
void apiFetch<unknown>("/api/ospf/catalog").then(raw=>{
|
||||||
interfaces: Array<{ areaId: string; interface: string }>
|
if(cancelled)return
|
||||||
instances: Array<unknown>
|
const parsed=ospfCatalogSchema.safeParse(raw)
|
||||||
neighbors: Array<unknown>
|
if(!parsed.success)throw new Error("Недостоверный каталог OSPF")
|
||||||
bfdSessions: Array<unknown>
|
const catalog=parsed.data
|
||||||
}>(`/api/servers/${ospfServerId}/ospf`)
|
const failure=catalog.failures.find(item=>String(item.serverId)===ospfServerId)
|
||||||
.then((data) => {
|
if(failure)throw new Error(failure.error)
|
||||||
if (cancelled) return
|
const interfaces=catalog.interfaces.filter(item=>String(item.serverId)===ospfServerId && !/^\(ref\s+\*.+\)$/.test(item.interface.trim()))
|
||||||
const selected = liveServers.find((s) => String(s.id) === ospfServerId)
|
const selected=liveServers.find(item=>String(item.id)===ospfServerId)
|
||||||
const visibleInterfaces = data.interfaces.filter((i) => !/^\(ref\s+\*.+\)$/.test(i.interface.trim()))
|
setMetaState({owner:ospfServerId,data:{interfaces:interfaces.length,areas:new Set(interfaces.map(item=>item.areaId)).size,serverName:selected?.name||selected?.host||ospfServerId},error:""})
|
||||||
setOspfMeta({
|
}).catch(error=>{
|
||||||
interfaces: visibleInterfaces.length,
|
if(!cancelled)setMetaState(previous=>({owner:ospfServerId,data:previous.owner===ospfServerId?previous.data:null,error:error instanceof Error?error.message:"Ошибка каталога OSPF"}))
|
||||||
areas: new Set(visibleInterfaces.map((i) => i.areaId)).size,
|
})
|
||||||
serverName: selected?.name || selected?.host || ospfServerId,
|
return()=>{cancelled=true}
|
||||||
})
|
},[apiFetch,liveServers,ospfServerId,useLiveData,previewTick])
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (cancelled) return
|
|
||||||
setOspfMeta(null)
|
|
||||||
})
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [apiFetch, liveServers, ospfServerId, useLiveData])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(()=>{
|
||||||
if (!useLiveData || !ospfServerId) return
|
if(!useLiveData||!ospfServerId)return
|
||||||
let cancelled = false
|
let cancelled=false
|
||||||
void apiFetch<{
|
queueMicrotask(()=>{
|
||||||
changedCount: number
|
if(cancelled)return
|
||||||
interfacesTotal: number
|
setPreviewState(previous=>({owner:previewKey,data:previous.owner===previewKey?previous.data:null,error:"",pending:true}))
|
||||||
interfaces: Array<{
|
void apiFetch<unknown>("/api/servers/"+ospfServerId+"/ospf/optimize/preview",{method:"POST",body:JSON.stringify({pingWeight:settings.pingWeight})}).then(raw=>{
|
||||||
interface: string
|
if(cancelled)return
|
||||||
currentCost: number
|
const preview=optimizerOspfPreviewSchema.safeParse(raw)
|
||||||
optimalCost: number
|
if(!preview.success||String(preview.data.serverId)!==ospfServerId||preview.data.pingWeight!==settings.pingWeight)throw new Error("Недостоверный preview OSPF или другой контекст")
|
||||||
score: number
|
setPreviewState({owner:previewKey,data:preview.data,error:"",pending:false})
|
||||||
pingMs: number
|
}).catch(error=>{
|
||||||
dlMbps: number
|
if(!cancelled)setPreviewState(previous=>({owner:previewKey,data:previous.owner===previewKey?previous.data:null,error:error instanceof Error?error.message:"Ошибка preview OSPF",pending:false}))
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
})
|
||||||
if (cancelled) return
|
return()=>{cancelled=true}
|
||||||
setOspfPreview(null)
|
},[apiFetch,ospfServerId,settings.pingWeight,useLiveData,previewKey,readAt,previewTick])
|
||||||
setOspfPreviewError(e instanceof Error ? e.message : "Ошибка preview OSPF")
|
|
||||||
})
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [apiFetch, ospfServerId, settings.pingWeight, useLiveData])
|
|
||||||
|
|
||||||
const ospfPreviewLoading = useLiveData && Boolean(ospfServerId) && !ospfPreview && !ospfPreviewError
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -862,6 +817,7 @@ function RouteOptimizerContent() {
|
|||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
|
{ospfMetaError && <p role="alert" className="text-sm text-warning">Каталог OSPF: {ospfMetaError}. Актуальность неизвестна.</p>}
|
||||||
{/* OSPF optimization from Route Optimizer */}
|
{/* OSPF optimization from Route Optimizer */}
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="OSPF"
|
title="OSPF"
|
||||||
@@ -892,13 +848,13 @@ function RouteOptimizerContent() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<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>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="ml-auto h-7 text-xs"
|
className="ml-auto h-7 text-xs"
|
||||||
onClick={() => void applyOspfOptimization()}
|
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")} />
|
<ZapIcon className={cn("size-3.5", ospfApplying && "animate-pulse")} />
|
||||||
{ospfApplying ? "Оптимизация…" : "Оптимизировать OSPF"}
|
{ospfApplying ? "Оптимизация…" : "Оптимизировать OSPF"}
|
||||||
@@ -917,6 +873,7 @@ function RouteOptimizerContent() {
|
|||||||
<div className="rounded-lg border bg-background/70 overflow-hidden">
|
<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">
|
<div className="px-3 py-2 border-b text-[11px] text-muted-foreground flex items-center justify-between">
|
||||||
<span>Preview изменений OSPF cost (до применения)</span>
|
<span>Preview изменений OSPF cost (до применения)</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={ospfApplying || ospfPreviewLoading} onClick={()=>setPreviewTick(value=>value+1)}>Обновить preview OSPF</Button>
|
||||||
<span>
|
<span>
|
||||||
{ospfPreviewLoading
|
{ospfPreviewLoading
|
||||||
? "расчёт…"
|
? "расчёт…"
|
||||||
@@ -926,10 +883,7 @@ function RouteOptimizerContent() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<RouteOptimizerOspfPreviewDataGrid
|
<RouteOptimizerOspfPreviewDataGrid
|
||||||
rows={(ospfPreview?.interfaces ?? []).map((row) => ({
|
rows={ospfPreview?.interfaces ?? []}
|
||||||
id: `${row.interface}-${row.currentCost}-${row.optimalCost}`,
|
|
||||||
...row,
|
|
||||||
}))}
|
|
||||||
error={ospfPreviewError || null}
|
error={ospfPreviewError || null}
|
||||||
loading={ospfPreviewLoading}
|
loading={ospfPreviewLoading}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -118,9 +118,9 @@ function RouteOptimizerOspfPreviewDataGrid({
|
|||||||
getRowId: (row) => row.id,
|
getRowId: (row) => row.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error && rows.length === 0) {
|
||||||
return (
|
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 (
|
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
|
<DataGridShell
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={rows.length}
|
recordCount={rows.length}
|
||||||
isLoading={loading}
|
isLoading={loading && rows.length === 0}
|
||||||
loadingMode="skeleton"
|
loadingMode="skeleton"
|
||||||
tableClassNames={{ headerRow: "border-b bg-muted/30", bodyRow: "group/row" }}
|
tableClassNames={{ headerRow: "border-b bg-muted/30", bodyRow: "group/row" }}
|
||||||
tableLayout={{ dense: true }}
|
tableLayout={{ dense: true }}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,19 @@
|
|||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 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А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 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)
|
## Блок 10.30 — согласованное чтение оптимизатора (2026-09-30)
|
||||||
|
|
||||||
- Inventory/rules/probes читаются параллельно и валидируются до публикации полного расчёта. Неизвестные типы, дубликаты, stale правила, плохие измерения и чужие ссылки на устройства отклоняются; missing payload не заменяется пустым массивом. Stored rules с live:false/stale:false допустимы как конфигурация БД для модели.
|
- Inventory/rules/probes читаются параллельно и валидируются до публикации полного расчёта. Неизвестные типы, дубликаты, stale правила, плохие измерения и чужие ссылки на устройства отклоняются; missing payload не заменяется пустым массивом. Stored rules с live:false/stale:false допустимы как конфигурация БД для модели.
|
||||||
|
|||||||
@@ -172,3 +172,7 @@ Live таймерный успех убран; demo симуляция явно
|
|||||||
## 10.30. Согласованные источники расчёта
|
## 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 пока явно ограничены.
|
Строгие 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.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {z} from "zod"
|
import {z} from "zod"
|
||||||
|
import {ospfOptimizeSchema} from "./ospf-response"
|
||||||
import {filtersResponseSchema} from "./filters-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 unique = <T,>(rows:T[],key:(row:T)=>string|number)=>new Set(rows.map(key)).size===rows.length
|
||||||
const measured = z.number().finite().nonnegative().nullable()
|
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(),
|
lastPingRttMs:measured,lastPingLossPct:z.number().finite().min(0).max(100).nullable(),
|
||||||
lastTxAvgMbps:measured,lastRxAvgMbps:measured,lastPingAt:z.string().datetime({offset:true}).nullable(),
|
lastTxAvgMbps:measured,lastRxAvgMbps:measured,lastPingAt:z.string().datetime({offset:true}).nullable(),
|
||||||
})).refine(rows=>unique(rows,row=>row.id))})
|
})).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,7 +1,7 @@
|
|||||||
import {test,expect,type Page} from "@playwright/test"
|
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 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 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}}))}
|
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})=>{
|
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)
|
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")
|
||||||
|
})
|
||||||
@@ -14,15 +14,15 @@ async function setup(page: Page, preview: "ok" | "empty" | "error" = "ok") {
|
|||||||
const path = new URL(route.request().url()).pathname
|
const path = new URL(route.request().url()).pathname
|
||||||
if (path.endsWith("/ospf/optimize/preview")) {
|
if (path.endsWith("/ospf/optimize/preview")) {
|
||||||
if (preview === "error") return route.fulfill({ status: 500, json: { error: "Расчёт недоступен" } })
|
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 }))
|
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: { interfaces, changes: interfaces, changedCount: interfaces.length, interfacesTotal: interfaces.length } })
|
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
|
return route.fulfill({ json: path === "/api/servers" ? servers
|
||||||
: path === "/api/auth/config" ? { required: false }
|
: path === "/api/auth/config" ? { required: false }
|
||||||
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: 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/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 === "/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 page.goto("/route-optimizer")
|
||||||
await expect(page.getByRole("table").filter({ hasText: "WAN-аплинк" }).locator("tr[data-row-id]")).toHaveCount(12)
|
await expect(page.getByRole("table").filter({ hasText: "WAN-аплинк" }).locator("tr[data-row-id]")).toHaveCount(12)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ test("Оптимизатор: live рекомендации не подтвер
|
|||||||
await page.route("**/api/**",r=>{
|
await page.route("**/api/**",r=>{
|
||||||
const p=new URL(r.request().url()).pathname
|
const p=new URL(r.request().url()).pathname
|
||||||
if(r.request().method()!=="GET"&&!p.endsWith("/preview"))writes++
|
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.goto("/route-optimizer")
|
||||||
await page.getByRole("button",{name:"BGP community (1)",exact:true}).click()
|
await page.getByRole("button",{name:"BGP community (1)",exact:true}).click()
|
||||||
|
|||||||
Reference in new issue
Block a user