fix(ui): учитывать область в стоимости соседа OSPF
This commit is contained in:
1 parent
2199950681
commit
b1080b0a05
6 files changed
+54
-9
No files matched your search
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { buildOspfCostIndex, ospfCostKey } from "@/lib/ospf-cost"
|
||||
import { normalizeOspfState, normalizeBfdState, ospfNodeState, type OspfNeighborState, type BfdState } from "@/lib/ospf-state"
|
||||
import { useMemo, useState, useEffect, useRef } from "react"
|
||||
import { ospfCatalogSchema, ospfServersSchema, ospfOptimizeSchema, type OspfCatalog } from "@/lib/ospf-response"
|
||||
@@ -139,9 +140,9 @@ function isRefInterfaceName(name: string): boolean {
|
||||
|
||||
// ─── backend → frontend mappers ───────────────────────────────────────────────
|
||||
|
||||
function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map<string, number>): OspfNeighbor {
|
||||
function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map<string, number | null>): OspfNeighbor {
|
||||
const state = normalizeOspfState(b.state)
|
||||
const costKey = `${b.serverId}::${b.interface}`
|
||||
const costKey = ospfCostKey(b.serverId, b.interface, b.areaId)
|
||||
return {
|
||||
id: `${b.serverId}-${b.id}`,
|
||||
localRouter: String(b.serverId),
|
||||
@@ -1203,10 +1204,7 @@ function OspfPageContent() {
|
||||
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => {
|
||||
if (isLive && liveData) {
|
||||
// Build interface→cost map for neighbor cost lookup
|
||||
const ifaceMap = new Map<string, number>()
|
||||
for (const iface of liveData.interfaces) {
|
||||
ifaceMap.set(`${iface.serverId}::${iface.interface}`, iface.cost)
|
||||
}
|
||||
const ifaceMap = buildOspfCostIndex(liveData.interfaces)
|
||||
|
||||
const items = liveData.interfaces
|
||||
.map(b => ({ ...backendToItem(b), dataUnknown: unknownIds.has(b.serverId) }))
|
||||
|
||||
@@ -26,11 +26,16 @@
|
||||
| 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.15, OSPF и проверка диапазона AS; далее показатели/URL/layout |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.16, cost OSPF по областям; далее показатели/URL/layout |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 10.16 — cost в области OSPF (2026-09-30)
|
||||
|
||||
- Индекс cost учитывает устройство, интерфейс и areaId. Одинаковое имя в разных областях не подставляет чужую стоимость. Конфликтующие шаблоны одного ключа и cost вне диапазона 1..65535 дают null; одинаковые подтверждённые значения сохраняются.
|
||||
- Четыре unit-теста (cost/state), семь E2E (области/конфликт плюс операции OSPF) и TypeScript прошли. Это только отображение соседей; оптимизатор и правила RouterOS не изменены. Далее backend nullable показатели, URL и визуальная приёмка. 97% окна использовано; локальный коммит без push.
|
||||
|
||||
## Блок 10.15 — диапазон AS в клиенте BGP (2026-09-30)
|
||||
|
||||
- Клиентская схема согласована со строгим backend parser: localAs/remoteAs — null или целое 1..4294967295. Ноль и переполнение отклоняют ответ; предыдущий снимок сохраняется как неизвестный, повторный успешный опрос восстанавливает данные.
|
||||
|
||||
@@ -100,3 +100,7 @@ Lock/readiness, существующее mm:servers:write, валидация п
|
||||
## 10.15. Диапазон AS клиента BGP
|
||||
|
||||
Схема клиента принимает null либо 1..4294967295, согласованно со строгим backend parser. Неверный ответ сохраняет кэш как неизвестный. 13 data E2E и TS прошли.
|
||||
|
||||
## 10.16. Cost в области OSPF
|
||||
|
||||
Ключ включает устройство/interface/areaId. Конфликты шаблонов и недопустимый cost остаются неизвестными. 4 unit, 7 E2E и TS прошли; алгоритм оптимизации не изменён.
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { buildOspfCostIndex, ospfCostKey } from "./ospf-cost"
|
||||
const iface = { serverId: 1, interface: "ether1", areaId: "0.0.0.0", cost: 7 }
|
||||
test("neighbor cost is scoped to device, interface and area", () => {
|
||||
const index = buildOspfCostIndex([iface, { ...iface, areaId: "0.0.0.1", cost: 42 }, { ...iface, serverId: 2, cost: 9 }])
|
||||
assert.equal(index.get(ospfCostKey(1,"ether1","0.0.0.0")), 7)
|
||||
assert.equal(index.get(ospfCostKey(1,"ether1","0.0.0.1")), 42)
|
||||
assert.equal(index.get(ospfCostKey(2,"ether1","0.0.0.0")), 9)
|
||||
assert.equal(index.get(ospfCostKey(3,"ether1","0.0.0.0")), undefined)
|
||||
})
|
||||
test("conflicting or invalid template costs do not choose a fabricated value", () => {
|
||||
const key = ospfCostKey(1,"ether1","0.0.0.0")
|
||||
assert.equal(buildOspfCostIndex([iface, iface]).get(key), 7)
|
||||
assert.equal(buildOspfCostIndex([iface, { ...iface, cost: 42 }, iface]).get(key), null)
|
||||
for (const cost of [0, -1, 65536, 1.5]) assert.equal(buildOspfCostIndex([{ ...iface, cost }]).get(key), null)
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
type InterfaceCost = { serverId: number; interface: string; areaId: string; cost: number }
|
||||
export const ospfCostKey = (serverId: number, iface: string, areaId: string) => JSON.stringify([serverId, iface, areaId])
|
||||
export function buildOspfCostIndex(interfaces: InterfaceCost[]): Map<string, number | null> {
|
||||
const result = new Map<string, number | null>()
|
||||
for (const iface of interfaces) {
|
||||
const key = ospfCostKey(iface.serverId, iface.interface, iface.areaId)
|
||||
const cost = Number.isInteger(iface.cost) && iface.cost >= 1 && iface.cost <= 65535 ? iface.cost : null
|
||||
result.set(key, result.has(key) && result.get(key) !== cost ? null : cost)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,16 +1,26 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
test("OSPF и BFD: переходные и неизвестные состояния не становятся Down", async ({ page }) => {
|
||||
let phase = 0
|
||||
const host = { serverId: 1, serverName: "Роутер", serverSite: "", serverCountry: "RU" }
|
||||
const iface = { ...host, id: "a", instance: "default", area: "backbone", areaId: "0.0.0.0", interface: "ether1", cost: 7, type: "ptp", disabled: false, inactive: false, priority: 1, helloInterval: 10, deadInterval: 40, useBfd: true }
|
||||
const states = ["full", "2-way", "ExStart", "exchange", "loading", "attempt", "init", "down", "future-state"]
|
||||
const catalog = { neighbors: states.map((state,i) => ({ ...host, id: String(i), address: "192.0.2."+i, routerId: "192.0.2."+i, instance: "default", area: "backbone", areaId: "0.0.0.0", interface: "ether1", state, uptime: "1h", stateChanges: 0, priority: 1 })), interfaces: [], instances: [], routes: [], failures: [], bfdSessions: ["up", "init", "down", "admindown", ""].map((state,i) => ({ ...host, id: String(i), localAddr: "192.0.2.1", remoteAddr: "198.51.100."+i, interface: "ether1", state, uptime: null, multihop: false, multiplier: 3, txInterval: 200, rxInterval: 200, holdTime: 600, packetsRx: 0, packetsTx: 0, stateChanges: 0 })) }
|
||||
const catalog = { neighbors: states.map((state,i) => ({ ...host, id: String(i), address: "192.0.2."+i, routerId: "192.0.2."+i, instance: "default", area: "backbone", areaId: i === 0 ? "0.0.0.1" : "0.0.0.0", interface: "ether1", state, uptime: "1h", stateChanges: 0, priority: 1 })), interfaces: [], instances: [], routes: [], failures: [], bfdSessions: ["up", "init", "down", "admindown", ""].map((state,i) => ({ ...host, id: String(i), localAddr: "192.0.2.1", remoteAddr: "198.51.100."+i, interface: "ether1", state, uptime: null, multihop: false, multiplier: 3, txInterval: 200, rxInterval: 200, holdTime: 600, packetsRx: 0, packetsTx: 0, stateChanges: 0 })) }
|
||||
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", r => { const path = new URL(r.request().url()).pathname; return r.fulfill({ json: path === "/api/ospf/catalog" ? catalog : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", country: "RU", enabled: true, status: null }] : path === "/api/auth/config" ? { required: false } : {} }) })
|
||||
await page.route("**/api/**", r => { const path = new URL(r.request().url()).pathname; return r.fulfill({ json: path === "/api/ospf/catalog" ? { ...catalog, interfaces: phase === 0 ? [] : [iface, { ...iface, id: "b", areaId: "0.0.0.1", cost: 42 }, ...(phase === 2 ? [{ ...iface, id: "c", cost: 9 }] : [])] } : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", country: "RU", enabled: true, status: null }] : path === "/api/auth/config" ? { required: false } : {} }) })
|
||||
await page.goto("/ospf")
|
||||
await page.getByRole("button", { name: "Соседи", exact: true }).click()
|
||||
const rows = page.locator("tr[data-row-id]")
|
||||
await expect(rows).toHaveCount(9)
|
||||
for (const row of await rows.all()) await expect(row.locator("td").nth(5)).toHaveText("—")
|
||||
for(const state of ["Full", "2-Way", "ExStart", "Exchange", "Loading", "Attempt", "Init", "Down", "Неизвестно"]) await expect(rows.getByText(state, { exact: true })).toHaveCount(1)
|
||||
phase = 1
|
||||
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(rows.first().locator("td").nth(5)).toHaveText("42")
|
||||
await expect(rows.nth(1).locator("td").nth(5)).toHaveText("7")
|
||||
phase = 2
|
||||
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(rows.first().locator("td").nth(5)).toHaveText("42")
|
||||
await expect(rows.nth(1).locator("td").nth(5)).toHaveText("—")
|
||||
await page.getByRole("button", { name: "BFD", exact: true }).click()
|
||||
await expect(rows).toHaveCount(5)
|
||||
for(const state of ["Up", "Init", "Down", "AdminDown", "Неизвестно"]) await expect(rows.getByText(state, { exact: true })).toHaveCount(1)
|
||||
|
||||
Reference in new issue
Block a user