fix(ui): связывать OSPF узлы по подтверждённым ID
This commit is contained in:
1 parent
7a68ae8184
commit
259c5c3014
16 files changed
+123
-41
No files matched your search
+20
-25
@@ -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<string, number | null>): OspfNeighbor {
|
||||
function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map<string, number | null>, serverByRouterId: Record<string, string>, labels: Record<string, string>): 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<string, number | nu
|
||||
localRouter: String(b.serverId),
|
||||
localLabel: b.serverName,
|
||||
localIface: b.interface,
|
||||
remoteRouter: b.routerId,
|
||||
remoteLabel: b.routerId, // no server name for remote peers
|
||||
remoteRouter: serverByRouterId[b.routerId] ?? b.routerId,
|
||||
remoteLabel: labels[serverByRouterId[b.routerId]] ?? b.routerId,
|
||||
remoteRouterId: b.routerId,
|
||||
area: b.areaId !== b.area ? `${b.area} (${b.areaId})` : b.area,
|
||||
state,
|
||||
@@ -180,8 +181,8 @@ function backendToBfdSession(b: BackendBfdSession): BfdSession {
|
||||
}
|
||||
|
||||
function backendToRoute(b: BackendOspfRoute): OspfRoute {
|
||||
const allowed: OspfRoute["type"][] = ["O", "O IA", "O E1", "O E2"]
|
||||
const type = allowed.includes(b.type) ? b.type : "O"
|
||||
const allowed: OspfRoute["type"][] = ["O", "O IA", "O E1", "O E2", "Unknown"]
|
||||
const type = allowed.includes(b.type) ? b.type : "Unknown"
|
||||
return {
|
||||
id: `${b.serverId}-${b.id}`,
|
||||
destination: b.destination,
|
||||
@@ -320,9 +321,9 @@ function buildLiveGraph(
|
||||
// Collect unique remote peers (that are NOT already local)
|
||||
const remoteMap = new Map<string, { label: string }>()
|
||||
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 (
|
||||
<g key={node.id} transform={`translate(${node.x},${node.y})`}
|
||||
<g data-ospf-node={node.id} key={node.id} transform={`translate(${node.x},${node.y})`}
|
||||
style={{ cursor: "pointer", transition: "opacity 0.2s" }}
|
||||
opacity={dimmed ? 0.25 : 1}
|
||||
onClick={e => { 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
|
||||
<span className="text-sm font-medium">{routes.some(r => r.dataUnknown) ? "Последний снимок: " : ""}{routes.length} маршрутов</span>
|
||||
{typeCount.map(([type, count]) => (
|
||||
<span key={type} className={cn("inline-flex items-center rounded border px-2 py-0.5 text-[11px] font-medium", routeTypeClass(type as OspfRoute["type"]))}>
|
||||
{type}: {count}
|
||||
{type === "Unknown" ? "Тип неизвестен" : type}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -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<string, string> = {}
|
||||
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[] => {
|
||||
|
||||
@@ -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")
|
||||
@@ -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<string, string>) {
|
||||
@@ -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) }))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { MikrotikClient } from "./mikrotik.js"
|
||||
import type { OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionRead, OspfRouteRead } from "../types/server.js"
|
||||
export type NullableFields<T, K extends keyof T> = Omit<T, K> & { [P in K]: T[P] | null }
|
||||
export type CatalogNeighbor = NullableFields<OspfNeighborRead, "priority" | "stateChanges">
|
||||
export type CatalogBfdSession = NullableFields<BfdSessionRead, "multiplier" | "txInterval" | "rxInterval" | "holdTime" | "packetsRx" | "packetsTx" | "stateChanges">
|
||||
export type CatalogBfdSession = NullableFields<BfdSessionRead, "multiplier" | "txInterval" | "rxInterval" | "holdTime" | "packetsRx" | "packetsTx" | "stateChanges" | "multihop">
|
||||
export type CatalogInterface = NullableFields<OspfInterfaceRead, "cost" | "type" | "priority" | "helloInterval" | "deadInterval" | "disabled" | "inactive" | "useBfd">
|
||||
export type CatalogInstance = NullableFields<OspfInstanceRead, "version" | "routerId" | "disabled" | "inactive">
|
||||
export type CatalogRoute = NullableFields<OspfRouteRead, "cost">
|
||||
export type CatalogRoute = Omit<NullableFields<OspfRouteRead, "cost">, "type"> & { type: OspfRouteRead["type"] | "Unknown" }
|
||||
export interface OspfCatalogCollections {
|
||||
neighbors: CatalogNeighbor[]; interfaces: CatalogInterface[]; instances: CatalogInstance[];
|
||||
bfdSessions: CatalogBfdSession[]; routes: CatalogRoute[];
|
||||
|
||||
@@ -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>): 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)
|
||||
@@ -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<string, OspfRouteKind> = { "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"
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono whitespace-nowrap">{b.serverLabel}</span>
|
||||
{b.multihop && (
|
||||
{b.multihop === null && <Chip>Режим неизвестен</Chip>}
|
||||
{b.multihop === true && (
|
||||
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">
|
||||
multihop
|
||||
</Chip>
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Тип", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
|
||||
@@ -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 блокируют контекст. Недоступный узел закрывается отдельным действием без сброса устройства.
|
||||
|
||||
@@ -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/приёмка.
|
||||
@@ -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))
|
||||
|
||||
@@ -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,{})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
type InstanceIdentity = { serverId: number; routerId: string | null }
|
||||
export function deriveOspfRouterIdentity(instances: InstanceIdentity[]) {
|
||||
const devices = new Map<string, { ids: Set<string>; unknown: boolean }>()
|
||||
for(const instance of instances) {
|
||||
const id=String(instance.serverId), value=instance.routerId?.trim()
|
||||
const entry=devices.get(id) ?? {ids:new Set<string>(),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<string,string> = {}
|
||||
for(const [device,entry] of devices) if(!entry.unknown && entry.ids.size===1)routerIds[device]=[...entry.ids][0]
|
||||
const reverse = new Map<string,string | null>()
|
||||
for(const [device,id] of Object.entries(routerIds)) reverse.set(id,reverse.has(id) && reverse.get(id)!==device ? null : device)
|
||||
const serverByRouterId: Record<string,string> = {}
|
||||
for(const [id,device] of reverse) if(device!==null)serverByRouterId[id]=device
|
||||
return {routerIds,serverByRouterId}
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user