diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index 88212a02..27276a01 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -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): OspfNeighbor { +function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map): 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() - 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) })) diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index fdbf1518..c6a7e3f0 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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. Ноль и переполнение отклоняют ответ; предыдущий снимок сохраняется как неизвестный, повторный успешный опрос восстанавливает данные. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 64c612ed..209515ed 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -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 прошли; алгоритм оптимизации не изменён. diff --git a/lib/ospf-cost.test.ts b/lib/ospf-cost.test.ts new file mode 100644 index 00000000..cc94b685 --- /dev/null +++ b/lib/ospf-cost.test.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) +}) diff --git a/lib/ospf-cost.ts b/lib/ospf-cost.ts new file mode 100644 index 00000000..a3b98cbb --- /dev/null +++ b/lib/ospf-cost.ts @@ -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 { + const result = new Map() + 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 +} diff --git a/tests/e2e/ospf-states.spec.ts b/tests/e2e/ospf-states.spec.ts index 2f469d63..36fcbb97 100644 --- a/tests/e2e/ospf-states.spec.ts +++ b/tests/e2e/ospf-states.spec.ts @@ -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)