From 259c5c3014ae0adf81b0c2dfec4169aabd575c6f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 20:19:37 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=81=D0=B2=D1=8F=D0=B7=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20OSPF=20=D1=83=D0=B7=D0=BB=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4?= =?UTF-8?q?=D1=91=D0=BD=D0=BD=D1=8B=D0=BC=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/ospf/page.tsx | 45 +++++++++---------- backend/src/routes/ospf-catalog.test.ts | 5 +++ backend/src/routes/ospf.ts | 6 +-- backend/src/services/ospf-catalog-poll.ts | 4 +- backend/src/services/ospf-route-parse.test.ts | 10 ++++- backend/src/services/ospf-route-parse.ts | 8 ++++ components/data-grids/ospf-bfd-data-grid.tsx | 5 ++- .../data-grids/ospf-neighbors-data-grid.tsx | 2 +- .../data-grids/ospf-routes-data-grid.tsx | 5 ++- docs/ux-ui-progress.md | 9 +++- docs/ux-ui-stage-10.md | 4 ++ lib/ospf-response.ts | 4 +- lib/ospf-router-identity.test.ts | 13 ++++++ lib/ospf-router-identity.ts | 19 ++++++++ tests/e2e/ospf-identity.spec.ts | 16 +++++++ tests/e2e/ospf-values.spec.ts | 9 +++- 16 files changed, 123 insertions(+), 41 deletions(-) create mode 100644 lib/ospf-router-identity.test.ts create mode 100644 lib/ospf-router-identity.ts create mode 100644 tests/e2e/ospf-identity.spec.ts diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index 19ad863d..97f46c9e 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -1,5 +1,6 @@ "use client" +import { deriveOspfRouterIdentity } from "@/lib/ospf-router-identity" import { buildOspfCostIndex, ospfCostKey } from "@/lib/ospf-cost" import { normalizeOspfState, normalizeBfdState, ospfNodeState, type OspfNeighborState, type BfdState } from "@/lib/ospf-state" import { useMemo, useState, useEffect, useRef, Suspense } from "react" @@ -76,7 +77,7 @@ interface OspfNeighbor { interface OspfRoute { id: string destination: string - type: "O" | "O IA" | "O E1" | "O E2" + type: "O" | "O IA" | "O E1" | "O E2" | "Unknown" cost: number | null nextHop: string via: string @@ -97,7 +98,7 @@ interface BfdSession { multiplier: number | null iface: string uptime: string | null - multihop: boolean + multihop: boolean | null rxInterval: number | null // ms holdTime: number | null // ms packetsRx: number | null @@ -121,7 +122,7 @@ function isRefInterfaceName(name: string): boolean { // ─── backend → frontend mappers ─────────────────────────────────────────────── -function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map): OspfNeighbor { +function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map, serverByRouterId: Record, labels: Record): OspfNeighbor { const state = normalizeOspfState(b.state) const costKey = ospfCostKey(b.serverId, b.interface, b.areaId) return { @@ -129,8 +130,8 @@ function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map() for (const n of neighbors) { - const remId = `remote-${n.remoteRouterId}` + const remId = n.remoteRouter !== n.remoteRouterId ? n.remoteRouter : `remote-${n.remoteRouterId}` if (!localMap.has(n.remoteRouter) && !remoteMap.has(remId)) { - remoteMap.set(remId, { label: n.remoteRouterId }) + remoteMap.set(remId, { label: n.remoteLabel }) } } @@ -342,7 +343,7 @@ function buildLiveGraph( const edges: GraphEdge[] = neighbors.map(n => ({ from: n.localRouter, - to: localMap.has(n.remoteRouter) ? n.remoteRouter : `remote-${n.remoteRouterId}`, + to: n.remoteRouter !== n.remoteRouterId || localMap.has(n.remoteRouter) ? n.remoteRouter : `remote-${n.remoteRouterId}`, state: n.state, cost: n.cost, })) @@ -460,7 +461,7 @@ function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDo (e.from === highlightId && e.to === node.id) || (e.to === highlightId && e.from === node.id)) return ( - { e.stopPropagation(); onNodeClick(isSel ? null : node.id) }}> @@ -537,12 +538,12 @@ function NodeDetailPanel({ const node = graphNodes.find(n => n.id === nodeId) if (!node) return null - const routerId = routerIds[nodeId] ?? "—" - const adjacencies = neighbors.filter(n => n.localRouter === nodeId || n.remoteRouter === nodeId) + const routerId = routerIds[nodeId] ?? (nodeId.startsWith("remote-") ? nodeId.slice(7) : "—") + const adjacencies = neighbors.filter(n => n.localRouter === nodeId || n.remoteRouter === nodeId || nodeId === `remote-${n.remoteRouterId}`) .map(n => { if (n.localRouter === nodeId) return { iface: n.localIface, peerLabel: n.remoteLabel, peerId: n.remoteRouterId, state: n.state, cost: n.cost, uptime: n.uptime } - return { iface: n.localIface, peerLabel: n.localLabel, peerId: routerId, state: n.state, cost: n.cost, uptime: n.uptime } + return { iface: n.localIface, peerLabel: n.localLabel, peerId: routerIds[n.localRouter] ?? "—", state: n.state, cost: n.cost, uptime: n.uptime } }) const ifaces = items.filter(i => i.routerKey === nodeId) const fullCnt = adjacencies.filter(a => a.state === "Full").length @@ -1010,7 +1011,7 @@ function RoutesTab({ routes, hasSearch }: { routes: OspfRoute[]; hasSearch: bool {routes.some(r => r.dataUnknown) ? "Последний снимок: " : ""}{routes.length} маршрутов {typeCount.map(([type, count]) => ( - {type}: {count} + {type === "Unknown" ? "Тип неизвестен" : type}: {count} ))} @@ -1190,21 +1191,15 @@ function OspfPageContent({ search, selectedNodeId, onSearch, onSelectNode, filte // Build interface→cost map for neighbor cost lookup const ifaceMap = buildOspfCostIndex(liveData.interfaces) + const { routerIds, serverByRouterId } = deriveOspfRouterIdentity(liveData.instances) + const labels = Object.fromEntries(liveServers.map(server => [String(server.id), server.name])) const items = liveData.interfaces .map(b => ({ ...backendToItem(b), dataUnknown: unknownIds.has(b.serverId) })) .filter((item) => !isRefInterfaceName(item.interfaceName)) - const neighbors = liveData.neighbors.map(b => ({ ...backendToNeighbor(b, ifaceMap), dataUnknown: unknownIds.has(b.serverId) })) + const neighbors = liveData.neighbors.map(b => ({ ...backendToNeighbor(b, ifaceMap, serverByRouterId, labels), dataUnknown: unknownIds.has(b.serverId) })) const bfdSessions = liveData.bfdSessions.map(b => ({ ...backendToBfdSession(b), dataUnknown: unknownIds.has(b.serverId) })) const routes = liveData.routes.map(b => ({ ...backendToRoute(b), dataUnknown: unknownIds.has(b.serverId) })) - // Build routerIds from instances - const routerIds: Record = {} - for (const inst of liveData.instances) { - if (inst.routerId && !routerIds[String(inst.serverId)]) { - routerIds[String(inst.serverId)] = inst.routerId - } - } - const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors) return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } } @@ -1228,7 +1223,7 @@ function OspfPageContent({ search, selectedNodeId, onSearch, onSelectNode, filte bfdSessions: MOCK_BFD, routes: MOCK_ROUTES, } - }, [isLive, liveData, unknownIds]) + }, [isLive, liveData, liveServers, unknownIds]) // ── server list for chips bar ──────────────────────────────────────────────── const ospfServers = useMemo((): OspfServerInfo[] => { diff --git a/backend/src/routes/ospf-catalog.test.ts b/backend/src/routes/ospf-catalog.test.ts index dac23f62..94a56ff7 100644 --- a/backend/src/routes/ospf-catalog.test.ts +++ b/backend/src/routes/ospf-catalog.test.ts @@ -66,3 +66,8 @@ assert.equal(parseCatalogRoutes(bfdServer, [{ ...rawRoute, "ospf-metric": "0" }] assert.equal(parseCatalogRoutes(bfdServer, [{ ...rawRoute, "ospf-metric": "42" }])[0].cost, 42) assert.equal(parseCatalogRoutes(bfdServer, [{ ...rawRoute, "ospf-metric": "42x" }])[0].cost, null) console.log("OSPF strict interfaces/instances/routes: absent/zero/fractional/flags, no distance-as-cost passed") + +assert.equal(parseBfdCatalogSessions(bfdServer, rawBfd("up"))[0].multihop, null) +assert.equal(parseBfdCatalogSessions(bfdServer, [{ ...rawBfd("up")[0], multihop: "false" }])[0].multihop, false) +assert.equal(parseCatalogRoutes(bfdServer, [rawRoute])[0].type, "Unknown") +assert.equal(parseCatalogRoutes(bfdServer, [{ ...rawRoute, "ospf-type": "intra-area" }])[0].type, "O") diff --git a/backend/src/routes/ospf.ts b/backend/src/routes/ospf.ts index 027d2372..8013ad13 100644 --- a/backend/src/routes/ospf.ts +++ b/backend/src/routes/ospf.ts @@ -9,7 +9,7 @@ import type { RosBfdSession, RosIpRoute, OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, OspfRouteRead, BfdSessionRead, } from "../types/server.js" -import { parseOspfGateway, parseOspfRouteType } from "../services/ospf-route-parse.js" +import { parseOspfGateway, parseOspfRouteType, parseOspfCatalogRouteType } from "../services/ospf-route-parse.js" import { z } from "zod" import { observedInteger, observedDurationMs, observedBoolean } from "../services/ospf-observed-values.js" import { pollOspfCatalog, readOspfCatalogRaw, type OspfCatalogCollections } from "../services/ospf-catalog-poll.js" @@ -123,7 +123,7 @@ export function parseBfdSessions(server: ServerRow, raw: RosBfdSession[], strict export function parseBfdCatalogSessions(server: ServerRow, raw: RosBfdSession[]) { return parseBfdSessions(server, raw, true).map((row, index) => { const source = raw[index] - return { ...row, multiplier: observedInteger(source.multiplier, 0, 255), txInterval: observedDurationMs(source["actual-tx-interval"]), rxInterval: observedDurationMs(source["required-min-rx"]), holdTime: observedDurationMs(source["hold-time"]), packetsRx: observedInteger(source["packets-rx"]), packetsTx: observedInteger(source["packets-tx"]), stateChanges: observedInteger(source["state-changes"]) } + return { ...row, multihop: observedBoolean(source.multihop), multiplier: observedInteger(source.multiplier, 0, 255), txInterval: observedDurationMs(source["actual-tx-interval"]), rxInterval: observedDurationMs(source["required-min-rx"]), holdTime: observedDurationMs(source["hold-time"]), packetsRx: observedInteger(source["packets-rx"]), packetsTx: observedInteger(source["packets-tx"]), stateChanges: observedInteger(source["state-changes"]) } }) } export function parseCatalogNeighbors(server: ServerRow, raw: RosOspfNeighbor[], areaMap: Map) { @@ -208,7 +208,7 @@ export function parseCatalogInstances(server: ServerRow, raw: RosOspfInstance[]) export function parseCatalogRoutes(server: ServerRow, raw: RosIpRoute[]) { return raw.flatMap((source, index) => { const rows = parseOspfRoutes(server, [{ ...source, ".id": source[".id"] ?? String(index) }]) - return rows.map(row => ({ ...row, cost: observedInteger(source["ospf-metric"], 0, 4294967295) })) + return rows.map(row => ({ ...row, type: parseOspfCatalogRouteType(source) ?? "Unknown", cost: observedInteger(source["ospf-metric"], 0, 4294967295) })) }) } diff --git a/backend/src/services/ospf-catalog-poll.ts b/backend/src/services/ospf-catalog-poll.ts index 7e7ca33a..5192578f 100644 --- a/backend/src/services/ospf-catalog-poll.ts +++ b/backend/src/services/ospf-catalog-poll.ts @@ -2,10 +2,10 @@ import type { MikrotikClient } from "./mikrotik.js" import type { OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionRead, OspfRouteRead } from "../types/server.js" export type NullableFields = Omit & { [P in K]: T[P] | null } export type CatalogNeighbor = NullableFields -export type CatalogBfdSession = NullableFields +export type CatalogBfdSession = NullableFields export type CatalogInterface = NullableFields export type CatalogInstance = NullableFields -export type CatalogRoute = NullableFields +export type CatalogRoute = Omit, "type"> & { type: OspfRouteRead["type"] | "Unknown" } export interface OspfCatalogCollections { neighbors: CatalogNeighbor[]; interfaces: CatalogInterface[]; instances: CatalogInstance[]; bfdSessions: CatalogBfdSession[]; routes: CatalogRoute[]; diff --git a/backend/src/services/ospf-route-parse.test.ts b/backend/src/services/ospf-route-parse.test.ts index 4c5521b0..27975c9d 100644 --- a/backend/src/services/ospf-route-parse.test.ts +++ b/backend/src/services/ospf-route-parse.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import type { RosIpRoute } from "../types/server.js" -import { parseOspfGateway, parseOspfRouteType } from "./ospf-route-parse.js" +import { parseOspfGateway, parseOspfRouteType, parseOspfCatalogRouteType } from "./ospf-route-parse.js" function route(partial: Partial): RosIpRoute { return { ".id": "*1", "dst-address": "10.0.0.0/8", ...partial } @@ -20,3 +20,11 @@ assert.deepEqual(parseOspfGateway(route({ gateway: "10.200.0.1%gre-msk-spb" })), }) console.log("ospf-route-parse.test.ts: ok") + +assert.equal(parseOspfCatalogRouteType(route({ ospf: "true" })), "Unknown") +assert.equal(parseOspfCatalogRouteType(route({ ospf: "true", "ospf-type": "future-type" })), "Unknown") +assert.equal(parseOspfCatalogRouteType(route({ ospf: "true", "ospf-type": "intra-area" })), "O") +assert.equal(parseOspfCatalogRouteType(route({ ospf: "true", "ospf-type": "inter-area" })), "O IA") +assert.equal(parseOspfCatalogRouteType(route({ "ospf-type": "ext-type-1" })), "O E1") +assert.equal(parseOspfCatalogRouteType(route({ "ospf-type": "nssa-ext-type-2" })), "O E2") +assert.equal(parseOspfCatalogRouteType(route({ static: "true" })), null) diff --git a/backend/src/services/ospf-route-parse.ts b/backend/src/services/ospf-route-parse.ts index e26fd2a5..5a027973 100644 --- a/backend/src/services/ospf-route-parse.ts +++ b/backend/src/services/ospf-route-parse.ts @@ -26,3 +26,11 @@ export function parseOspfGateway(r: RosIpRoute): { nextHop: string; via: string via: iface || (r.interface ?? "—"), } } + +/** Only an explicit known subtype proves the intra/inter/external class. */ +export function parseOspfCatalogRouteType(route: RosIpRoute): OspfRouteKind | "Unknown" | null { + if (parseOspfRouteType(route) === null) return null + const kinds: Record = { "intra-area": "O", internal: "O", "inter-area": "O IA", "ext-type-1": "O E1", "external-type-1": "O E1", "type-1": "O E1", "ext-1": "O E1", e1: "O E1", "nssa-ext-type-1": "O E1", "ext-type-2": "O E2", "external-type-2": "O E2", "type-2": "O E2", "ext-2": "O E2", e2: "O E2", "nssa-ext-type-2": "O E2" } + const raw = (route["ospf-type"] ?? route.type ?? "").trim().toLowerCase().replace(/^ospf[- ]/, "") + return kinds[raw] ?? "Unknown" +} diff --git a/components/data-grids/ospf-bfd-data-grid.tsx b/components/data-grids/ospf-bfd-data-grid.tsx index d20ca85f..6f40f914 100644 --- a/components/data-grids/ospf-bfd-data-grid.tsx +++ b/components/data-grids/ospf-bfd-data-grid.tsx @@ -31,7 +31,7 @@ export interface BfdSessionRow { multiplier: number | null iface: string uptime: string | null - multihop: boolean + multihop: boolean | null rxInterval: number | null holdTime: number | null packetsRx: number | null @@ -85,7 +85,8 @@ function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) { return (
{b.serverLabel} - {b.multihop && ( + {b.multihop === null && Режим неизвестен} + {b.multihop === true && ( multihop diff --git a/components/data-grids/ospf-neighbors-data-grid.tsx b/components/data-grids/ospf-neighbors-data-grid.tsx index 2feb3f9c..ae125ef1 100644 --- a/components/data-grids/ospf-neighbors-data-grid.tsx +++ b/components/data-grids/ospf-neighbors-data-grid.tsx @@ -194,7 +194,7 @@ function OspfNeighborsDataGrid({ const rowSelection = useMemo(() => { if (!selectedRouterId) return {} const match = neighbors.find( - (n) => n.localRouter === selectedRouterId || n.remoteRouter === selectedRouterId, + (n) => n.localRouter === selectedRouterId || n.remoteRouter === selectedRouterId || selectedRouterId === `remote-${n.remoteRouterId}`, ) return match ? { [match.id]: true as const } : {} }, [neighbors, selectedRouterId]) diff --git a/components/data-grids/ospf-routes-data-grid.tsx b/components/data-grids/ospf-routes-data-grid.tsx index 928c703a..bfa8670f 100644 --- a/components/data-grids/ospf-routes-data-grid.tsx +++ b/components/data-grids/ospf-routes-data-grid.tsx @@ -22,7 +22,7 @@ export interface OspfRouteRow { id: string dataUnknown?: boolean destination: string - type: "O" | "O IA" | "O E1" | "O E2" + type: "O" | "O IA" | "O E1" | "O E2" | "Unknown" cost: number | null nextHop: string via: string @@ -32,6 +32,7 @@ export interface OspfRouteRow { } function routeTypeClass(type: OspfRouteRow["type"]) { + if (type === "Unknown") return "bg-muted text-muted-foreground border-border" if (type === "O") return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25" if (type === "O IA") return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25" if (type === "O E1") return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/25" @@ -70,7 +71,7 @@ function OspfRoutesDataGrid({ routes, emptyTitle }: OspfRoutesDataGridProps) { routeTypeClass(row.original.type), )} > - {row.original.type} + {row.original.type === "Unknown" ? "Тип неизвестен" : row.original.type} ), meta: { headerTitle: "Тип", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD }, diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index ded3340a..39a9d56b 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,18 @@ | 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.21, поиск и узел OSPF в URL; далее метаданные/layout | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.22, достоверные метаданные/узлы OSPF; далее layout/keyboard | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.22 — метаданные и однозначные узлы OSPF (2026-09-30) + +- Неизвестный subtype OSPF-маршрута показывается нейтрально, без intra-area fallback. Unknown multihop BFD не становится false. Legacy parsers совместимы; строгий каталог проверен raw fixtures. +- Router ID main/имя/null/несколько разных ID устройства не выдаются за один фактический IPv4 ID. Дубли между устройствами не связывают соседа с произвольным сервером. Однозначные наблюдаемые ID сопоставляют известные узлы; неизвестный удалённый узел остаётся отдельным, без угадывания. +- Проверки: 13 разных metadata/identity/links/states/values E2E, два identity unit, backend live-maps/build, TS/lint прошли. Сначала identity click попал на анимированный bbox группы; сценарий повторно прошёл с реальным hit target — неподвижным circle. Одна старая warning frontend, две backend; зависимость inventory labels useMemo исправлена. +- Далее desktop/mobile, keyboard/focus, reduced motion и профильная production-приёмка OSPF. Последнее чтение: 17% окна использовано; локальный коммит без push, реальные RouterOS/DB/JWT не проверены. + ## Блок 10.21 — поиск и выбранный узел OSPF (2026-09-30) - Поиск текущего раздела и node сохраняются в URL, переживают reload/history. Устройство/таблица/топология используют текущий срез; invalid/repeated/oversized q/node блокируют контекст. Недоступный узел закрывается отдельным действием без сброса устройства. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index e6ce1df2..96defe38 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -136,3 +136,7 @@ Device/tab сохранены в URL; invalid/missing context не расшир ## 10.21. Поиск и узел q/node сохраняются вместе с server/tab. Таблица и топология отражают один срез; пустой поиск не утверждает отсутствие конфигурации. 28 разных E2E, 3 unit, TS/lint и frontend production прошли. Accessible search/close; операция явно относится ко всем интерфейсам устройства. Далее metadata/layout/keyboard. + +## 10.22. Подтверждённые метаданные/узлы + +Route subtype и BFD multihop неизвестны без наблюдения. Router ID refs/ambiguity не создают ложную связь устройств; только уникальный фактический ID объединяет узлы. 13 E2E, 2 identity unit, backend regression/build и TS/lint прошли. Далее layout/keyboard/motion/приёмка. diff --git a/lib/ospf-response.ts b/lib/ospf-response.ts index 69830b89..8789db0a 100644 --- a/lib/ospf-response.ts +++ b/lib/ospf-response.ts @@ -6,8 +6,8 @@ export const ospfCatalogSchema = z.object({ neighbors: z.array(base.extend({ address: z.string(), routerId: z.string(), instance: z.string(), area: z.string(), areaId: z.string(), interface: z.string(), state: z.string(), uptime: z.string().nullable(), stateChanges: counter.nullable(), priority: counter.max(255).nullable() })), interfaces: z.array(base.extend({ instance: z.string(), area: z.string(), areaId: z.string(), interface: z.string(), cost: counter.max(65535).nullable(), type: z.string().nullable(), disabled: z.boolean().nullable(), inactive: z.boolean().nullable(), priority: counter.max(255).nullable(), helloInterval: z.number().finite().nonnegative().nullable(), deadInterval: z.number().finite().nonnegative().nullable(), useBfd: z.boolean().nullable() })), instances: z.array(base.extend({ name: z.string(), routerId: z.string().nullable(), version: z.union([z.literal(2), z.literal(3)]).nullable(), disabled: z.boolean().nullable(), inactive: z.boolean().nullable(), redistribute: z.string() })), - bfdSessions: z.array(base.extend({ localAddr: z.string(), remoteAddr: z.string(), interface: z.string(), state: z.string(), uptime: z.string().nullable(), multihop: z.boolean(), multiplier: counter.max(255).nullable(), txInterval: z.number().finite().nonnegative().nullable(), rxInterval: z.number().finite().nonnegative().nullable(), holdTime: z.number().finite().nonnegative().nullable(), packetsRx: counter.nullable(), packetsTx: counter.nullable(), stateChanges: counter.nullable() })), - routes: z.array(base.extend({ destination: z.string(), type: z.enum(["O", "O IA", "O E1", "O E2"]), cost: counter.max(4294967295).nullable(), nextHop: z.string(), via: z.string(), area: z.string() })), + bfdSessions: z.array(base.extend({ localAddr: z.string(), remoteAddr: z.string(), interface: z.string(), state: z.string(), uptime: z.string().nullable(), multihop: z.boolean().nullable(), multiplier: counter.max(255).nullable(), txInterval: z.number().finite().nonnegative().nullable(), rxInterval: z.number().finite().nonnegative().nullable(), holdTime: z.number().finite().nonnegative().nullable(), packetsRx: counter.nullable(), packetsTx: counter.nullable(), stateChanges: counter.nullable() })), + routes: z.array(base.extend({ destination: z.string(), type: z.enum(["O", "O IA", "O E1", "O E2", "Unknown"]), cost: counter.max(4294967295).nullable(), nextHop: z.string(), via: z.string(), area: z.string() })), failures: z.array(z.object({ serverId: z.number().int().positive(), serverName: z.string(), error: z.string() })), }).refine(data => { const failed = new Set(data.failures.map(f => f.serverId)) diff --git a/lib/ospf-router-identity.test.ts b/lib/ospf-router-identity.test.ts new file mode 100644 index 00000000..09123382 --- /dev/null +++ b/lib/ospf-router-identity.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { deriveOspfRouterIdentity } from "./ospf-router-identity" +test("only an unambiguous observed Router ID links devices",()=>{ + const result=deriveOspfRouterIdentity([{serverId:1,routerId:"192.0.2.1"},{serverId:1,routerId:"192.0.2.1"},{serverId:2,routerId:"192.0.2.2"}]) + assert.deepEqual(result.routerIds,{"1":"192.0.2.1","2":"192.0.2.2"});assert.equal(result.serverByRouterId["192.0.2.2"],"2") +}) +test("named/unresolved/multiple/duplicate Router IDs never guess a device",()=>{ + for(const routerId of [null,"main","0.0.0.0","999.0.0.1",""]) assert.deepEqual(deriveOspfRouterIdentity([{serverId:1,routerId}]).routerIds,{}) + assert.deepEqual(deriveOspfRouterIdentity([{serverId:1,routerId:"192.0.2.1"},{serverId:1,routerId:"192.0.2.2"}]).routerIds,{}) + assert.deepEqual(deriveOspfRouterIdentity([{serverId:1,routerId:"192.0.2.1"},{serverId:1,routerId:null}]).routerIds,{}) + assert.deepEqual(deriveOspfRouterIdentity([{serverId:1,routerId:"192.0.2.1"},{serverId:2,routerId:"192.0.2.1"}]).serverByRouterId,{}) +}) diff --git a/lib/ospf-router-identity.ts b/lib/ospf-router-identity.ts new file mode 100644 index 00000000..fda9485c --- /dev/null +++ b/lib/ospf-router-identity.ts @@ -0,0 +1,19 @@ +type InstanceIdentity = { serverId: number; routerId: string | null } +export function deriveOspfRouterIdentity(instances: InstanceIdentity[]) { + const devices = new Map; unknown: boolean }>() + for(const instance of instances) { + const id=String(instance.serverId), value=instance.routerId?.trim() + const entry=devices.get(id) ?? {ids:new Set(),unknown:false} + const parts=value?.split(".") + if(!value || value === "0.0.0.0" || !parts || parts.length!==4 || parts.some(part=>!/^\d{1,3}$/.test(part) || Number(part)>255)) entry.unknown=true + else entry.ids.add(value) + devices.set(id,entry) + } + const routerIds: Record = {} + for(const [device,entry] of devices) if(!entry.unknown && entry.ids.size===1)routerIds[device]=[...entry.ids][0] + const reverse = new Map() + for(const [device,id] of Object.entries(routerIds)) reverse.set(id,reverse.has(id) && reverse.get(id)!==device ? null : device) + const serverByRouterId: Record = {} + for(const [id,device] of reverse) if(device!==null)serverByRouterId[id]=device + return {routerIds,serverByRouterId} +} diff --git a/tests/e2e/ospf-identity.spec.ts b/tests/e2e/ospf-identity.spec.ts new file mode 100644 index 00000000..9aad7065 --- /dev/null +++ b/tests/e2e/ospf-identity.spec.ts @@ -0,0 +1,16 @@ +import { test,expect } from "@playwright/test" +test("OSPF: ссылка main не связывает устройства, фактический Router ID связывает",async({page})=>{ + let known=false + const host=(id:number)=>({serverId:id,serverName:"Роутер "+id,serverSite:"",serverCountry:"RU"}) + 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"?{neighbors:[1,2].map(id=>({...host(id),id:"n",address:"192.0.2."+(3-id),routerId:"192.0.2."+(3-id),instance:"default",area:"backbone",areaId:"0.0.0.0",interface:"ether1",state:"Full",uptime:"1h",priority:1,stateChanges:0})),interfaces:[],instances:[1,2].map(id=>({...host(id),id:"i",name:"default",routerId:id===2&&!known?"main":"192.0.2."+id,version:2,disabled:false,inactive:false,redistribute:""})),bfdSessions:[],routes:[],failures:[]}:path==="/api/servers"?[1,2].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,enabled:true,status:null,country:"RU"})):path==="/api/auth/config"?{required:false}:{}})}) + await page.goto("/ospf?tab=neighbors") + const nodes=page.locator("[data-ospf-node]") + await expect(nodes).toHaveCount(3) + await expect(page.locator('[data-ospf-node="remote-192.0.2.2"]')).toBeVisible() + known=true;await page.getByRole("button",{name:"Обновить",exact:true}).click() + await expect(nodes).toHaveCount(2) + await page.locator('[data-ospf-node="2"] > circle[r="34"]').click() + const details=page.getByRole("region",{name:"Детали узла OSPF"});await expect(details).toContainText("192.0.2.2") + await expect(page).toHaveURL(/node=2/) +}) diff --git a/tests/e2e/ospf-values.spec.ts b/tests/e2e/ospf-values.spec.ts index 77548f6e..941d011c 100644 --- a/tests/e2e/ospf-values.spec.ts +++ b/tests/e2e/ospf-values.spec.ts @@ -2,22 +2,27 @@ import { test, expect } from "@playwright/test" test("OSPF/BFD: неизвестные значения отличаются от наблюдаемого нуля", async ({ page }) => { const host = { serverId: 1, serverName: "Роутер", serverSite: "", serverCountry: "RU" } const neighbor = { ...host, id: "n", address: "192.0.2.2", routerId: "192.0.2.2", instance: "default", area: "backbone", areaId: "0.0.0.0", interface: "ether1", state: "full", uptime: null, stateChanges: null, priority: null } - const bfd = { ...host, id: "b", localAddr: "192.0.2.1", remoteAddr: "192.0.2.2", interface: "ether1", state: "up", uptime: null, multihop: false, multiplier: null, txInterval: null, rxInterval: null, holdTime: null, packetsRx: null, packetsTx: null, stateChanges: null } + const bfd = { ...host, id: "b", localAddr: "192.0.2.1", remoteAddr: "192.0.2.2", interface: "ether1", state: "up", uptime: null, multihop: null, multiplier: null, txInterval: null, rxInterval: null, holdTime: null, packetsRx: null, packetsTx: null, stateChanges: null } let zero = false 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" ? { neighbors: [{ ...neighbor, ...(zero ? { priority: 0, stateChanges: 0 } : {}) }], bfdSessions: [{ ...bfd, ...(zero ? { multiplier: 0, txInterval: 0, rxInterval: 0.001, holdTime: 0, packetsRx: 0, packetsTx: 0, stateChanges: 0 } : {}) }], interfaces: [], instances: [], routes: [], failures: [] } : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, country: "RU", 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" ? { neighbors: [{ ...neighbor, ...(zero ? { priority: 0, stateChanges: 0 } : {}) }], bfdSessions: [{ ...bfd, ...(zero ? { multihop: false, multiplier: 0, txInterval: 0, rxInterval: 0.001, holdTime: 0, packetsRx: 0, packetsTx: 0, stateChanges: 0 } : {}) }], interfaces: [], instances: [], routes: [{ ...host, id:"r", destination:"192.0.2.0/24", type:zero?"O IA":"Unknown", cost:zero?0:null, nextHop:"192.0.2.2", via:"ether1", area:"backbone" }], failures: [] } : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, country: "RU", status: null }] : path === "/api/auth/config" ? { required: false } : {} }) }) await page.goto("/ospf") await page.getByRole("button", { name: "Соседи", exact: true }).click() const cells=page.locator("tr[data-row-id]").locator("td") await expect(cells.last()).toHaveText("—") await page.getByRole("button", { name: "BFD", exact: true }).click() + await expect(page.getByText("Режим неизвестен",{exact:true})).toBeVisible() await expect(cells.nth(6)).toHaveText("— / —") for (const index of [7,8,9,10,11]) await expect(cells.nth(index)).toHaveText("—") await expect(page.getByText("Общее число полученных пакетов неизвестно.", { exact: false })).toBeVisible() + await page.getByRole("button",{name:"Маршруты",exact:true}).click() + await expect(cells.nth(1)).toHaveText("Тип неизвестен") + await page.getByRole("button",{name:"BFD",exact:true}).click() zero=true await page.getByRole("button", { name: "Обновить", exact: true }).click() await expect(cells.nth(6)).toHaveText("0ms / 0.001ms") await expect(cells.nth(7)).toHaveText("0ms") + await expect(page.getByText("Режим неизвестен",{exact:true})).toHaveCount(0) for (const index of [8,9,10,11]) await expect(cells.nth(index)).toHaveText("0") await expect(page.getByText("Всего получено: 0 пакетов.", { exact: false })).toBeVisible() await page.getByRole("button", { name: "Соседи", exact: true }).click()