fix(ui): сохранять согласованные данные оптимизатора
This commit is contained in:
1 parent
59f0f82176
commit
f93dfe34c2
7 files changed
+139
-53
No files matched your search
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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 { 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"
|
||||||
@@ -29,7 +30,6 @@ import {
|
|||||||
type OptimizerData,
|
type OptimizerData,
|
||||||
type OptimizerSettings,
|
type OptimizerSettings,
|
||||||
type OptimizerApiServer,
|
type OptimizerApiServer,
|
||||||
type RouteOptimizerSpeedProbe,
|
|
||||||
buildLiveOptimizerData,
|
buildLiveOptimizerData,
|
||||||
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
|
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
|
||||||
mapApiServersToTopology,
|
mapApiServersToTopology,
|
||||||
@@ -409,6 +409,9 @@ function RouteOptimizerContent() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [data, setData] = useState<OptimizerData | null>(null)
|
const [data, setData] = useState<OptimizerData | null>(null)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
|
const [readAt, setReadAt] = useState<string | null>(null)
|
||||||
|
const reader = useRef({ alive: true, epoch: 0 })
|
||||||
|
useEffect(() => { const state = reader.current; state.alive = true; return () => { state.alive = false; state.epoch++ } }, [])
|
||||||
const [settings, setSettings] = useState<OptimizerSettings>(DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS)
|
const [settings, setSettings] = useState<OptimizerSettings>(DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS)
|
||||||
const settingsRef = useRef(settings)
|
const settingsRef = useRef(settings)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -474,52 +477,45 @@ function RouteOptimizerContent() {
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async (override?: OptimizerSettings) => {
|
const load = useCallback(async (override?: OptimizerSettings) => {
|
||||||
const s = override ?? settingsRef.current
|
const epoch = ++reader.current.epoch
|
||||||
|
const current = () => reader.current.alive && reader.current.epoch === epoch
|
||||||
|
const settings = override ?? settingsRef.current
|
||||||
|
if (!current()) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError("")
|
|
||||||
try {
|
try {
|
||||||
if (!useLiveData) {
|
if (!useLiveData) {
|
||||||
await new Promise((r) => setTimeout(r, 450))
|
await new Promise(resolve => setTimeout(resolve, 450))
|
||||||
setData(buildMockData(s))
|
if (!current()) return
|
||||||
setLiveJumpHosts([])
|
setData(buildMockData(settings)); setError(""); setReadAt(null)
|
||||||
setLiveExitNodes([])
|
|
||||||
setLiveServers([])
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const rows = await apiFetch<OptimizerApiServer[]>("/api/servers")
|
async function readPart<T>(path:string, parse:(raw:unknown)=>T, label:string) {
|
||||||
const enabledRows = rows.filter((r) => r.enabled)
|
let raw:unknown
|
||||||
setLiveServers(enabledRows)
|
try { raw = await apiFetch<unknown>(path) }
|
||||||
setOspfServerId((prev) => (prev && enabledRows.some((r) => String(r.id) === prev) ? prev : String(enabledRows[0]?.id ?? "")))
|
catch (error) { throw new Error(label + ": " + (error instanceof Error ? error.message : "ошибка API")) }
|
||||||
const { jumpHosts, exitNodes } = mapApiServersToTopology(rows)
|
try { return parse(raw) } catch { throw new Error(label + ": недостоверный ответ API") }
|
||||||
setLiveJumpHosts(jumpHosts)
|
|
||||||
setLiveExitNodes(exitNodes)
|
|
||||||
|
|
||||||
let rulesets: Array<{ serverId: string; rules: import("@/lib/data").FilterRule[] }> | null = null
|
|
||||||
try {
|
|
||||||
const fr = await apiFetch<{ rulesets: Array<{ serverId: string; rules: import("@/lib/data").FilterRule[] }> }>(
|
|
||||||
"/api/filters/rules",
|
|
||||||
)
|
|
||||||
rulesets = fr.rulesets ?? null
|
|
||||||
} catch {
|
|
||||||
rulesets = null
|
|
||||||
}
|
}
|
||||||
let speedProbes: RouteOptimizerSpeedProbe[] = []
|
const [inventory,rules,probes] = await Promise.allSettled([
|
||||||
try {
|
readPart("/api/servers",raw=>optimizerServersSchema.parse(raw),"Каталог устройств"),
|
||||||
const sp = await apiFetch<{ probes: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes")
|
readPart("/api/filters/rules",raw=>optimizerRulesSchema.parse(raw),"Правила Community"),
|
||||||
speedProbes = sp.probes ?? []
|
readPart("/api/uptime/speed-probes",raw=>optimizerProbesSchema.parse(raw),"Измерения speed-probe"),
|
||||||
} catch {
|
])
|
||||||
speedProbes = []
|
if (!current()) return
|
||||||
}
|
const failures = [inventory,rules,probes].flatMap(result=>result.status==="rejected"?[result.reason instanceof Error?result.reason.message:"Ошибка данных"]:[])
|
||||||
|
if (inventory.status!=="fulfilled" || rules.status!=="fulfilled" || probes.status!=="fulfilled") throw new Error(failures.join("; "))
|
||||||
setData(buildLiveOptimizerData(rows, rulesets, s, speedProbes))
|
const rows = inventory.value
|
||||||
} catch (e) {
|
const ids = new Set(rows.map(row=>String(row.id)))
|
||||||
setError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
if (rules.value.rulesets.some(set=>!ids.has(set.serverId)) || probes.value.probes.some(probe=>!ids.has(probe.srcServerId)||!ids.has(probe.dstServerId))) throw new Error("Правила или измерения относятся к неизвестному устройству")
|
||||||
setData(null)
|
const next = buildLiveOptimizerData(rows, rules.value.rulesets, settings, probes.value.probes)
|
||||||
setLiveJumpHosts([])
|
const {jumpHosts,exitNodes} = mapApiServersToTopology(rows)
|
||||||
setLiveExitNodes([])
|
const enabledRows=rows.filter(row=>row.enabled)
|
||||||
setLiveServers([])
|
setLiveServers(enabledRows);setLiveJumpHosts(jumpHosts);setLiveExitNodes(exitNodes)
|
||||||
|
setOspfServerId(previous=>previous && enabledRows.some(row=>String(row.id)===previous)?previous:String(enabledRows[0]?.id??""))
|
||||||
|
setData(next);setError("");setReadAt(new Date().toISOString())
|
||||||
|
} catch (error) {
|
||||||
|
if (current()) setError(error instanceof Error ? error.message : "Ошибка загрузки данных")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (current()) setLoading(false)
|
||||||
}
|
}
|
||||||
}, [apiFetch, useLiveData])
|
}, [apiFetch, useLiveData])
|
||||||
|
|
||||||
@@ -590,26 +586,26 @@ function RouteOptimizerContent() {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: "Home роутеров",
|
label: "Home роутеров",
|
||||||
value: homeCount,
|
value: useLiveData && !data ? "—" : homeCount,
|
||||||
sub: `${wanCount} WAN-аплинков`,
|
sub: useLiveData && !data ? "Количество WAN неизвестно" : `${wanCount} WAN-аплинков`,
|
||||||
icon: <MonitorIcon className="size-4" />,
|
icon: <MonitorIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "JumpHost",
|
label: "JumpHost",
|
||||||
value: jh.length,
|
value: useLiveData && !data ? "—" : jh.length,
|
||||||
sub: jhSub,
|
sub: jhSub,
|
||||||
icon: <ServerIcon className="size-4" />,
|
icon: <ServerIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Exit Node",
|
label: "Exit Node",
|
||||||
value: ex.length,
|
value: useLiveData && !data ? "—" : ex.length,
|
||||||
sub: exSub,
|
sub: exSub,
|
||||||
icon: <NetworkIcon className="size-4" />,
|
icon: <NetworkIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Переключений",
|
label: "Переключений",
|
||||||
value: totalSwitches,
|
value: useLiveData && !data ? "—" : totalSwitches,
|
||||||
sub: totalSwitches > 0 ? "расчётные рекомендации" : "модель не предлагает переключений",
|
sub: useLiveData && !data ? "Расчёт пока неизвестен" : totalSwitches > 0 ? "расчётные рекомендации" : "модель не предлагает переключений",
|
||||||
icon: <ZapIcon className="size-4" />,
|
icon: <ZapIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -625,7 +621,7 @@ function RouteOptimizerContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyOspfOptimization() {
|
async function applyOspfOptimization() {
|
||||||
if (!ospfServerId) return
|
if (!ospfServerId || loading || error || !data) return
|
||||||
setOspfApplying(true)
|
setOspfApplying(true)
|
||||||
setOspfApplyError("")
|
setOspfApplyError("")
|
||||||
setOspfApplyResult(null)
|
setOspfApplyResult(null)
|
||||||
@@ -748,7 +744,8 @@ function RouteOptimizerContent() {
|
|||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap px-0.5">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap px-0.5">
|
||||||
<TopologyBar />
|
<TopologyBar />
|
||||||
<span className="text-border">·</span>
|
<span className="text-border">·</span>
|
||||||
<span>Обновлено: {data?.updatedAt ?? "—"}</span>
|
<span>Расчёт: {data?.updatedAt ?? "—"}</span>
|
||||||
|
{readAt && <span role="status">Ответ получен {new Date(readAt).toLocaleTimeString("ru-RU")}. Время браузера, не измерения.</span>}
|
||||||
<span className="text-border">·</span>
|
<span className="text-border">·</span>
|
||||||
<span>Авто {useLiveData ? `${settings.probeIntervalMin} мин` : "30 с"}</span>
|
<span>Авто {useLiveData ? `${settings.probeIntervalMin} мин` : "30 с"}</span>
|
||||||
<Chip>{useLiveData ? "Данные API · расчёт модели" : "Демо · симуляция"}</Chip>
|
<Chip>{useLiveData ? "Данные API · расчёт модели" : "Демо · симуляция"}</Chip>
|
||||||
@@ -782,8 +779,8 @@ function RouteOptimizerContent() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
|
<div role="alert" className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
|
||||||
<AlertCircleIcon className="size-4 shrink-0" />{error}
|
<AlertCircleIcon className="size-4 shrink-0" />{error}. {data ? "Последний полный снимок; актуальность не подтверждена." : "Данные оптимизатора неизвестны."}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -901,7 +898,7 @@ function RouteOptimizerContent() {
|
|||||||
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={!ospfServerId || ospfApplying}
|
disabled={loading || Boolean(error) || !data || !ospfServerId || ospfApplying}
|
||||||
>
|
>
|
||||||
<ZapIcon className={cn("size-3.5", ospfApplying && "animate-pulse")} />
|
<ZapIcon className={cn("size-3.5", ospfApplying && "animate-pulse")} />
|
||||||
{ospfApplying ? "Оптимизация…" : "Оптимизировать OSPF"}
|
{ospfApplying ? "Оптимизация…" : "Оптимизировать OSPF"}
|
||||||
|
|||||||
@@ -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.29; Community оптимизатора показывает расчёт, далее strict read/OSPF |
|
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.30; согласованное чтение оптимизатора, далее OSPF preview/apply |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
## Блок 10.30 — согласованное чтение оптимизатора (2026-09-30)
|
||||||
|
|
||||||
|
- Inventory/rules/probes читаются параллельно и валидируются до публикации полного расчёта. Неизвестные типы, дубликаты, stale правила, плохие измерения и чужие ссылки на устройства отклоняются; missing payload не заменяется пустым массивом. Stored rules с live:false/stale:false допустимы как конфигурация БД для модели.
|
||||||
|
- Частичный отказ оставляет предыдущий полный snapshot/topology/inventory, с явным предупреждением; начальная неизвестность отображается «—». OSPF apply блокируется при pending/ошибке исходного расчёта. Model fallback метрик пока сохранён и обозначен, сетевой алгоритм не изменён.
|
||||||
|
- Epoch и alive защищают от позднего расчёта/unmount. Успешное получение API и время построения модели разделены; время браузера не называется измерением устройства.
|
||||||
|
- Проверки: 16 E2E (начальные HTTP/invalid сбои трёх частей, повтор, partial cache, race, tables/preview/trust), два unit schema, TS/lint и production frontend (34 страницы) прошли. Race повторно прошёл после cleanup reader.
|
||||||
|
- Далее 10.31: OSPF preview/apply receipts, источник/устройство, права, synchronous lock и reread после ошибки. Legacy OSPF metadata читает старый endpoint; unknown/current model metrics ещё требуют профильной доработки. Последняя квота: 70% окна, 42% недели использовано. Локальный коммит без push.
|
||||||
|
|
||||||
## Блок 10.29 — достоверность рекомендаций оптимизатора (2026-09-30)
|
## Блок 10.29 — достоверность рекомендаций оптимизатора (2026-09-30)
|
||||||
|
|
||||||
- Live Community больше не показывает успех по таймеру: действия симуляции доступны только в демо. Переключение backend/mode размонтирует прежний контекст; таймеры симуляции отменяются, повтор синхронно заблокирован.
|
- Live Community больше не показывает успех по таймеру: действия симуляции доступны только в демо. Переключение backend/mode размонтирует прежний контекст; таймеры симуляции отменяются, повтор синхронно заблокирован.
|
||||||
|
|||||||
@@ -168,3 +168,7 @@ server/q/route/table принадлежат экрану; default replace, вы
|
|||||||
## 10.29. Community: расчёт и симуляция
|
## 10.29. Community: расчёт и симуляция
|
||||||
|
|
||||||
Live таймерный успех убран; demo симуляция явно подписана, cleanup/lock сохранены. Модельная базовая цепочка и индекс в баллах обозначены; автоприменение недоступно, ECMP/RPF/VRF локальные. Исправлены mock-ID матриц. 6 E2E, TS/lint прошли. Далее strict чтение и OSPF операции; раздел ещё не принят.
|
Live таймерный успех убран; demo симуляция явно подписана, cleanup/lock сохранены. Модельная базовая цепочка и индекс в баллах обозначены; автоприменение недоступно, ECMP/RPF/VRF локальные. Исправлены mock-ID матриц. 6 E2E, TS/lint прошли. Далее strict чтение и OSPF операции; раздел ещё не принят.
|
||||||
|
|
||||||
|
## 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 пока явно ограничены.
|
||||||
@@ -127,7 +127,7 @@ export interface OptimizerApiServer {
|
|||||||
site: string
|
site: string
|
||||||
country: string
|
country: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
status: "online" | "offline" | null
|
status: "online" | "offline" | "degraded" | null
|
||||||
latency: number | null
|
latency: number | null
|
||||||
model: string | null
|
model: string | null
|
||||||
wanUplinks?: Array<{
|
wanUplinks?: Array<{
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import {test} from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {optimizerServersSchema,optimizerRulesSchema,optimizerProbesSchema} from "./route-optimizer-response"
|
||||||
|
const server={id:1,name:"Router",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,latency:0,status:null}
|
||||||
|
const probe={id:"p",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",dstInterface:"ether2",enabled:true,lastPingRttMs:0,lastPingLossPct:0,lastTxAvgMbps:0,lastRxAvgMbps:null,lastPingAt:"2026-09-30T00:00:00Z"}
|
||||||
|
test("optimizer observed zero/null and empty sources are distinct from missing",()=>{
|
||||||
|
assert.equal(optimizerServersSchema.parse([server])[0].latency,0)
|
||||||
|
assert.equal(optimizerProbesSchema.parse({probes:[probe]}).probes[0].lastTxAvgMbps,0)
|
||||||
|
assert.equal(optimizerProbesSchema.parse({probes:[probe]}).probes[0].lastRxAvgMbps,null)
|
||||||
|
assert.ok(optimizerProbesSchema.safeParse({probes:[]}).success)
|
||||||
|
assert.ok(!optimizerProbesSchema.safeParse({}).success)
|
||||||
|
assert.ok(optimizerRulesSchema.safeParse({rulesets:[],live:false,stale:false}).success)
|
||||||
|
assert.ok(!optimizerRulesSchema.safeParse({rulesets:[],stale:true}).success)
|
||||||
|
})
|
||||||
|
test("optimizer refuses unknown types, duplicate inventory/WAN and invalid metrics",()=>{
|
||||||
|
for(const value of [[{...server,type:undefined}],[server,server],[{...server,latency:-1}],[{...server,wanUplinks:[{id:"wan",name:"WAN",isp:"ISP",iface:"ether1",ip:"",maxDl:0,maxUl:10}]}]]) assert.ok(!optimizerServersSchema.safeParse(value).success)
|
||||||
|
for(const value of [[probe,probe],[{...probe,lastPingRttMs:-1}],[{...probe,lastPingLossPct:101}],[{...probe,lastPingAt:"yesterday"}],[{...probe,lastTxAvgMbps:undefined}]]) assert.ok(!optimizerProbesSchema.safeParse({probes:value}).success)
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {z} from "zod"
|
||||||
|
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()
|
||||||
|
export const optimizerServersSchema=z.array(z.object({
|
||||||
|
id:z.number().int().positive(),name:z.string(),host:z.string().min(1),type:z.enum(["home-router","jump-host","exit-node"]),
|
||||||
|
enabled:z.boolean(),country:z.string(),site:z.string().optional().default(""),
|
||||||
|
status:z.enum(["online","offline","degraded"]).nullable().optional().default(null),
|
||||||
|
latency:measured.optional().default(null),model:z.string().nullable().optional().default(null),
|
||||||
|
wanUplinks:z.array(z.object({id:z.string().min(1),name:z.string(),isp:z.string(),iface:z.string(),ip:z.string(),maxDl:z.number().finite().positive(),maxUl:z.number().finite().positive()})).refine(rows=>unique(rows,row=>row.id)).optional(),
|
||||||
|
})).refine(rows=>unique(rows,row=>row.id))
|
||||||
|
export const optimizerRulesSchema=filtersResponseSchema.refine(data=>data.stale!==true)
|
||||||
|
export const optimizerProbesSchema=z.object({probes:z.array(z.object({
|
||||||
|
id:z.string().min(1),srcServerId:z.string().min(1),dstServerId:z.string().min(1),srcInterface:z.string(),dstInterface:z.string(),enabled:z.boolean(),
|
||||||
|
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))})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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:[]}:{}
|
||||||
|
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)
|
||||||
|
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return p===paths[part]&&failing?r.fulfill(malformed?{json:{}}:{status:503,json:{error:"Источник недоступен"}}):r.fulfill({json:body(p)})})
|
||||||
|
await page.goto("/route-optimizer")
|
||||||
|
await expect(page.getByRole("alert").filter({hasText:"Данные оптимизатора неизвестны"})).toBeVisible()
|
||||||
|
const stats=page.getByLabel("Сводка оптимизатора");await expect(stats).toContainText("Расчёт пока неизвестен");await expect(stats).not.toContainText("модель не предлагает переключений")
|
||||||
|
await expect(page.getByRole("table").filter({hasText:"WAN-аплинк"})).toHaveCount(0)
|
||||||
|
failing=false;await page.getByRole("button",{name:"Обновить",exact:true}).click()
|
||||||
|
await expect(page.getByRole("table").filter({hasText:"WAN-аплинк"}).locator("tr[data-row-id]")).toHaveCount(1)
|
||||||
|
await expect(page.getByRole("alert").filter({hasText:"Данные оптимизатора неизвестны"})).toHaveCount(0)
|
||||||
|
})
|
||||||
|
for(const part of ["servers","rules","probes"] as const)test("Оптимизатор: частичный сбой сохраняет полный снимок "+part,async({page})=>{
|
||||||
|
let failing=false;await health(page)
|
||||||
|
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return p===paths[part]&&failing?r.fulfill({status:503,json:{error:"Отказ части"}}):r.fulfill({json:body(p,failing?"Новый каталог":"Исходный каталог")})})
|
||||||
|
await page.goto("/route-optimizer")
|
||||||
|
const grid=page.getByRole("table").filter({hasText:"WAN-аплинк"});await expect(grid.locator("tr[data-row-id]")).toHaveCount(1)
|
||||||
|
await expect(grid.getByRole("columnheader",{name:/Исходный каталог 2/})).toBeVisible()
|
||||||
|
failing=true;await page.getByRole("button",{name:"Обновить",exact:true}).click()
|
||||||
|
await expect(page.getByRole("alert").filter({hasText:"Последний полный снимок"})).toBeVisible()
|
||||||
|
await expect(grid.getByRole("columnheader",{name:/Исходный каталог 2/})).toBeVisible()
|
||||||
|
await expect(page.getByRole("button",{name:"Оптимизировать OSPF",exact:true})).toBeDisabled()
|
||||||
|
})
|
||||||
|
test("Оптимизатор: поздний расчёт не заменяет новый",async({page})=>{
|
||||||
|
let version="Исходный",reads=0,started=false,release!:()=>void
|
||||||
|
const gate=new Promise<void>(resolve=>{release=resolve});await health(page)
|
||||||
|
await page.route("**/api/**",async r=>{const p=new URL(r.request().url()).pathname,value=body(p,version)
|
||||||
|
if(p===paths.rules&&++reads===2){started=true;await gate}
|
||||||
|
return r.fulfill({json:value})})
|
||||||
|
await page.goto("/route-optimizer");const grid=page.getByRole("table").filter({hasText:"WAN-аплинк"});await expect(grid.locator("tr[data-row-id]")).toHaveCount(1)
|
||||||
|
await page.getByRole("button",{name:"Настройки оптимизатора",exact:true}).click()
|
||||||
|
version="Старый";await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect.poll(()=>started).toBe(true)
|
||||||
|
version="Новый";await page.getByRole("button",{name:"Пересчитать",exact:true}).click()
|
||||||
|
await expect(grid.getByRole("columnheader",{name:/Новый 2/})).toBeVisible()
|
||||||
|
const response=page.waitForResponse(r=>new URL(r.url()).pathname===paths.rules);release();await(await response).finished()
|
||||||
|
await page.evaluate(()=>new Promise<void>(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve()))))
|
||||||
|
await expect(grid.getByRole("columnheader",{name:/Новый 2/})).toBeVisible()
|
||||||
|
})
|
||||||
Reference in new issue
Block a user