diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx
index 27276a01..d4885c07 100644
--- a/app/(main)/ospf/page.tsx
+++ b/app/(main)/ospf/page.tsx
@@ -67,7 +67,7 @@ interface OspfNeighbor {
state: OspfNeighborState
cost: number | null
uptime: string
- priority: number
+ priority: number | null
dataUnknown?: boolean
}
@@ -91,16 +91,16 @@ interface BfdSession {
localAddr: string
remoteAddr: string
state: BfdState
- interval: number // txInterval ms
- multiplier: number
+ interval: number | null // txInterval ms
+ multiplier: number | null
iface: string
uptime: string | null
multihop: boolean
- rxInterval: number // ms
- holdTime: number // ms
- packetsRx: number
- packetsTx: number
- stateChanges: number
+ rxInterval: number | null // ms
+ holdTime: number | null // ms
+ packetsRx: number | null
+ packetsTx: number | null
+ stateChanges: number | null
dataUnknown?: boolean
}
@@ -109,7 +109,7 @@ interface BfdSession {
interface BackendNeighbor {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
address: string; routerId: string; instance: string; area: string; areaId: string
- interface: string; state: string; uptime: string | null; stateChanges: number; priority: number
+ interface: string; state: string; uptime: string | null; stateChanges: number | null; priority: number | null
}
interface BackendInterface {
@@ -123,8 +123,8 @@ interface BackendInterface {
interface BackendBfdSession {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
localAddr: string; remoteAddr: string; interface: string; state: string; uptime: string | null
- multihop: boolean; multiplier: number; txInterval: number; rxInterval: number; holdTime: number
- packetsRx: number; packetsTx: number; stateChanges: number
+ multihop: boolean; multiplier: number | null; txInterval: number | null; rxInterval: number | null; holdTime: number | null
+ packetsRx: number | null; packetsTx: number | null; stateChanges: number | null
}
interface BackendOspfRoute {
@@ -1056,13 +1056,7 @@ function BfdTab({ sessions, dataIncomplete }: { sessions: BfdSession[]; dataInco
const upCount = sessions.filter(b => b.state === "Up").length
const downCount = sessions.filter(b => b.state === "Down" || b.state === "AdminDown").length
const initCount = sessions.filter(b => b.state === "Init").length
- const totalPkts = sessions.reduce((s, b) => s + b.packetsRx, 0)
-
- function fmtMs(ms: number) {
- if (!ms) return "—"
- if (ms < 1000) return `${ms}ms`
- return `${(ms / 1000).toFixed(1)}s`
- }
+ const totalPkts = sessions.some(b => b.packetsRx === null) ? null : sessions.reduce((sum, b) => sum + (b.packetsRx ?? 0), 0)
return (
@@ -1118,8 +1112,9 @@ function BfdTab({ sessions, dataIncomplete }: { sessions: BfdSession[]; dataInco
BFD обнаруживает сбои быстрее OSPF Hello/Dead таймеров.
- Tx / Rx — интервалы отправки и приёма контрольных пакетов.
- {!dataIncomplete && totalPkts > 0 && Всего получено: {totalPkts.toLocaleString()} пакетов.}
+ {!dataIncomplete && totalPkts === null && Общее число полученных пакетов неизвестно.}
+ TX факт — наблюдаемый интервал отправки; RX минимум — требуемый минимальный интервал.
+ {!dataIncomplete && totalPkts !== null && Всего получено: {totalPkts.toLocaleString()} пакетов.}
)}
diff --git a/backend/package.json b/backend/package.json
index 43455c73..da99e140 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -22,7 +22,7 @@
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
"test:backups": "tsx src/services/s3-backup-client.test.ts",
- "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts",
+ "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync && npm run test:ros-file",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
},
diff --git a/backend/src/routes/ospf-catalog.test.ts b/backend/src/routes/ospf-catalog.test.ts
index 184a64ff..80f487ee 100644
--- a/backend/src/routes/ospf-catalog.test.ts
+++ b/backend/src/routes/ospf-catalog.test.ts
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
-import { createOspfRoutes, parseBfdSessions } from "./ospf.js"
+import { createOspfRoutes, parseBfdSessions, parseBfdCatalogSessions, parseCatalogNeighbors } from "./ospf.js"
import { emptyOspfCatalog, pollOspfCatalog, readOspfCatalogRaw, type OspfCatalogCollections } from "../services/ospf-catalog-poll.js"
import { servers } from "../db/schema.js"
const devices = [{ id: 1, name: "Первый" }, { id: 2, name: "Второй" }]
@@ -37,3 +37,15 @@ assert.equal(parseBfdSessions(bfdServer, rawBfd("down"), true)[0].state, "Down")
assert.equal(parseBfdSessions(bfdServer, rawBfd("up"), true)[0].state, "Up")
assert.equal(parseBfdSessions(bfdServer, rawBfd(), false)[0].state, "Down")
console.log("OSPF strict BFD missing/blank/known states and legacy compatibility passed")
+
+const missingBfd = parseBfdCatalogSessions(bfdServer, rawBfd("up"))[0]
+for (const field of ["multiplier", "txInterval", "rxInterval", "holdTime", "packetsRx", "packetsTx", "stateChanges"] as const) assert.equal(missingBfd[field], null)
+const zeroBfd = parseBfdCatalogSessions(bfdServer, [{ ...rawBfd("up")[0], multiplier: "0", "actual-tx-interval": "0ms", "required-min-rx": "1us", "hold-time": "1s200ms", "packets-rx": "0", "packets-tx": "9", "state-changes": "0" }])[0]
+assert.equal(zeroBfd.multiplier, 0); assert.equal(zeroBfd.txInterval, 0); assert.equal(zeroBfd.rxInterval, 0.001); assert.equal(zeroBfd.holdTime, 1200); assert.equal(zeroBfd.packetsRx, 0); assert.equal(zeroBfd.packetsTx, 9); assert.equal(zeroBfd.stateChanges, 0)
+assert.equal(parseBfdCatalogSessions(bfdServer, [{ ...rawBfd("up")[0], "desired-tx-interval": "200ms" }])[0].txInterval, null)
+const neighborSource = { ".id": "*1", address: "192.0.2.2", "router-id": "192.0.2.2", instance: "default", area: "backbone", interface: "ether1", state: "full" }
+assert.equal(parseCatalogNeighbors(bfdServer, [neighborSource], new Map())[0].priority, null)
+const neighborZero = parseCatalogNeighbors(bfdServer, [{ ...neighborSource, priority: "0", "state-changes": "0" }], new Map())[0]
+assert.equal(neighborZero.priority, 0); assert.equal(neighborZero.stateChanges, 0)
+for (const priority of ["junk", "1junk", "256", "-1"]) assert.equal(parseCatalogNeighbors(bfdServer, [{ ...neighborSource, priority }], new Map())[0].priority, null)
+console.log("OSPF strict values: missing/zero/microseconds/compound, no desired-as-actual and priority bounds passed")
diff --git a/backend/src/routes/ospf.ts b/backend/src/routes/ospf.ts
index b10e1fc3..a59ec4e2 100644
--- a/backend/src/routes/ospf.ts
+++ b/backend/src/routes/ospf.ts
@@ -11,6 +11,7 @@ import type {
} from "../types/server.js"
import { parseOspfGateway, parseOspfRouteType } from "../services/ospf-route-parse.js"
import { z } from "zod"
+import { observedInteger, observedDurationMs } from "../services/ospf-observed-values.js"
import { pollOspfCatalog, readOspfCatalogRaw, type OspfCatalogCollections } from "../services/ospf-catalog-poll.js"
type ServerRow = typeof servers.$inferSelect
@@ -119,6 +120,16 @@ 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"]) }
+ })
+}
+export function parseCatalogNeighbors(server: ServerRow, raw: RosOspfNeighbor[], areaMap: Map
) {
+ return parseNeighbors(server, raw, areaMap).map((row, index) => ({ ...row, priority: observedInteger(raw[index].priority, 0, 255), stateChanges: observedInteger(raw[index]["state-changes"]) }))
+}
+
// ── parsers ───────────────────────────────────────────────────────────────────
function parseNeighbors(
@@ -554,7 +565,7 @@ const catalogDefaults: OspfCatalogDependencies = {
readCatalog: async server => {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } = await readOspfCatalogRaw(MikrotikClient.fromServer(server))
const areaMap = buildAreaMap(areas)
- return { neighbors: parseNeighbors(server, neighbors, areaMap), interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap), instances: parseInstances(server, instances), bfdSessions: parseBfdSessions(server, bfdSessions, true), routes: parseOspfRoutes(server, ipRoutes) }
+ return { neighbors: parseCatalogNeighbors(server, neighbors, areaMap), interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap), instances: parseInstances(server, instances), bfdSessions: parseBfdCatalogSessions(server, bfdSessions), routes: parseOspfRoutes(server, ipRoutes) }
},
}
export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalogDefaults): FastifyPluginAsyncZod {
diff --git a/backend/src/services/ospf-catalog-poll.ts b/backend/src/services/ospf-catalog-poll.ts
index 05ed02f4..74920c46 100644
--- a/backend/src/services/ospf-catalog-poll.ts
+++ b/backend/src/services/ospf-catalog-poll.ts
@@ -1,8 +1,11 @@
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 interface OspfCatalogCollections {
- neighbors: OspfNeighborRead[]; interfaces: OspfInterfaceRead[]; instances: OspfInstanceRead[];
- bfdSessions: BfdSessionRead[]; routes: OspfRouteRead[];
+ neighbors: CatalogNeighbor[]; interfaces: OspfInterfaceRead[]; instances: OspfInstanceRead[];
+ bfdSessions: CatalogBfdSession[]; routes: OspfRouteRead[];
}
export const emptyOspfCatalog = (): OspfCatalogCollections => ({ neighbors: [], interfaces: [], instances: [], bfdSessions: [], routes: [] })
export async function readOspfCatalogRaw(client: Pick) {
diff --git a/backend/src/services/ospf-observed-values.test.ts b/backend/src/services/ospf-observed-values.test.ts
new file mode 100644
index 00000000..67d92fc7
--- /dev/null
+++ b/backend/src/services/ospf-observed-values.test.ts
@@ -0,0 +1,7 @@
+import assert from "node:assert/strict"
+import { observedInteger, observedDurationMs } from "./ospf-observed-values.js"
+for (const raw of [undefined, "", " ", "1x", "-1", "1.5", "9007199254740992"]) assert.equal(observedInteger(raw), null)
+assert.equal(observedInteger("0"), 0); assert.equal(observedInteger("255", 0, 255), 255); assert.equal(observedInteger("256", 0, 255), null)
+for (const raw of [undefined, "", "-1s", "12junk", "200", "1s2s", "60:00", "00:99:00", "1s junk"]) assert.equal(observedDurationMs(raw), null)
+for (const [raw, ms] of [["0ms", 0], ["0", 0], ["1us", 0.001], ["1s200ms", 1200], ["1h2m3s", 3723000], ["01:02:03.5", 3723500], ["02:03", 123000]] as const) assert.equal(observedDurationMs(raw), ms)
+console.log("OSPF observed values: absent/zero/invalid/unsafe/units/clock passed")
diff --git a/backend/src/services/ospf-observed-values.ts b/backend/src/services/ospf-observed-values.ts
new file mode 100644
index 00000000..0b84dea3
--- /dev/null
+++ b/backend/src/services/ospf-observed-values.ts
@@ -0,0 +1,28 @@
+/** Values read from RouterOS; absence is not a measured zero. */
+export function observedInteger(value: string | undefined, min = 0, max = Number.MAX_SAFE_INTEGER): number | null {
+ if (value === undefined || !/^\d+$/.test(value.trim())) return null
+ const number = Number(value.trim())
+ return Number.isSafeInteger(number) && number >= min && number <= max ? number : null
+}
+/** RouterOS time in milliseconds. Bare numbers are ambiguous, except explicit zero. */
+export function observedDurationMs(value: string | undefined): number | null {
+ const text = value?.trim()
+ if (!text) return null
+ if (text === "0") return 0
+ const clock = /^(?:(\d+):)?(\d{1,2}):(\d{2})(\.\d+)?$/.exec(text)
+ if (clock) {
+ const [, hours = "0", minutes, seconds, fraction = ""] = clock
+ if (Number(minutes) >= 60 || Number(seconds) >= 60) return null
+ const ms = (Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds + fraction)) * 1000
+ return Number.isFinite(ms) && ms <= Number.MAX_SAFE_INTEGER ? ms : null
+ }
+ const units: Record = { w: 604800000, d: 86400000, h: 3600000, m: 60000, s: 1000, ms: 1, us: 0.001, ns: 0.000001 }
+ const pattern = /(\d+(?:\.\d+)?)(ms|us|ns|w|d|h|m|s)/g
+ let cursor = 0, total = 0
+ const seen = new Set()
+ for (const part of text.matchAll(pattern)) {
+ if (part.index !== cursor || seen.has(part[2])) return null
+ seen.add(part[2]); cursor += part[0].length; total += Number(part[1]) * units[part[2]]
+ }
+ return cursor === text.length && Number.isFinite(total) && total <= Number.MAX_SAFE_INTEGER ? total : null
+}
diff --git a/components/data-grids/ospf-bfd-data-grid.tsx b/components/data-grids/ospf-bfd-data-grid.tsx
index dec2aab2..d20ca85f 100644
--- a/components/data-grids/ospf-bfd-data-grid.tsx
+++ b/components/data-grids/ospf-bfd-data-grid.tsx
@@ -27,16 +27,16 @@ export interface BfdSessionRow {
localAddr: string
remoteAddr: string
state: BfdState
- interval: number
- multiplier: number
+ interval: number | null
+ multiplier: number | null
iface: string
uptime: string | null
multihop: boolean
- rxInterval: number
- holdTime: number
- packetsRx: number
- packetsTx: number
- stateChanges: number
+ rxInterval: number | null
+ holdTime: number | null
+ packetsRx: number | null
+ packetsTx: number | null
+ stateChanges: number | null
}
function stateClass(state: BfdSessionRow["state"]) {
@@ -61,8 +61,8 @@ function Chip({ children, color }: { children: React.ReactNode; color?: string }
)
}
-function fmtMs(ms: number) {
- if (!ms) return "—"
+function fmtMs(ms: number | null) {
+ if (ms === null) return "—"
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
@@ -177,7 +177,7 @@ function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
},
{
id: "intervals",
- header: () => Tx / Rx,
+ header: () => TX факт / RX минимум,
enableSorting: false,
cell: ({ row }) => {
const b = row.original
@@ -209,7 +209,7 @@ function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
accessorKey: "multiplier",
header: ({ column }) => ,
cell: ({ row }) => (
- {row.original.multiplier}
+ {row.original.multiplier ?? "—"}
),
meta: {
headerTitle: "Mult",
@@ -225,7 +225,7 @@ function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
),
cell: ({ row }) => (
- {row.original.packetsRx.toLocaleString()}
+ {row.original.packetsRx?.toLocaleString() ?? "—"}
),
meta: {
@@ -242,7 +242,7 @@ function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
),
cell: ({ row }) => (
- {row.original.packetsTx.toLocaleString()}
+ {row.original.packetsTx?.toLocaleString() ?? "—"}
),
meta: {
@@ -258,7 +258,7 @@ function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
),
cell: ({ row }) => (
- {row.original.stateChanges}
+ {row.original.stateChanges ?? "—"}
),
meta: {
headerTitle: "Переходы",
diff --git a/components/data-grids/ospf-neighbors-data-grid.tsx b/components/data-grids/ospf-neighbors-data-grid.tsx
index 2249b215..958f68a5 100644
--- a/components/data-grids/ospf-neighbors-data-grid.tsx
+++ b/components/data-grids/ospf-neighbors-data-grid.tsx
@@ -32,7 +32,7 @@ export interface OspfNeighborRow {
state: OspfNeighborState
cost: number | null
uptime: string
- priority: number
+ priority: number | null
}
function stateClass(state: OspfNeighborRow["state"]) {
@@ -177,7 +177,7 @@ function OspfNeighborsDataGrid({
accessorKey: "priority",
header: ({ column }) => ,
cell: ({ row }) => (
- {row.original.priority}
+ {row.original.priority ?? "—"}
),
meta: {
headerTitle: "Prio",
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index ef8b2eb8..cbc85b39 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.17, регрессия OSPF/BGP; далее nullable показатели/URL/layout |
+| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.18, nullable BFD/priority соседей; далее интерфейсы/маршруты/URL/layout |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
+## Блок 10.18 — неизвестные показатели BFD и приоритет соседей (2026-09-30)
+
+- Строгие DTO каталога отделены от legacy: BFD multiplier/tx/rx/hold/counters и neighbor priority/stateChanges допускают null. Отсутствующие/некорректные значения не получают defaults; priority=0 и наблюдаемые нули сохраняются. Actual TX не подменяется desired TX; единицы us/ms/s и составные/clock durations проверяются без частичного parseInt.
+- Таблицы показывают «—» для null, 0/0ms для измеренного нуля. Подпись TX факт / RX минимум соответствует источникам. Общий Rx неизвестен при неполном составе, подтверждённая сумма 0 видна. Старые endpoints и алгоритм оптимизатора совместимы.
+- Проверки: 15 OSPF E2E (values/data/states/operations), test:live-maps с raw fixtures, frontend TypeScript и backend build прошли. Lint без ошибок; один старый warning frontend и два backend. MCP ReUI checklist получен; vendored компоненты/тема не изменены.
+- Следующий блок: nullable interfaces/instances/route metric и readiness записи, затем URL/layout. Окно сбросилось; использовано 3%, недельного 31%. Локальный коммит без push, реальные RouterOS/DB/JWT не проверены.
+
## Блок 10.17 — strict BFD и контрольная регрессия (2026-09-30)
- Strict-state режим BFD parser закреплён прямыми backend fixtures: missing/blank → Unknown, known up/down сохраняются; legacy missing → Down остаётся совместимым. Изменений сетевых правил нет.
diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md
index 5be6e693..e0ac2461 100644
--- a/docs/ux-ui-stage-10.md
+++ b/docs/ux-ui-stage-10.md
@@ -120,3 +120,7 @@ Lock/readiness, существующее mm:servers:write, валидация п
Порядок: отдельные catalog DTO и strict parser → backend fixtures (missing/zero/malformed/known) → Zod → таблицы/сводка/топология → readiness операций → API E2E → профильная сборка. Старые endpoints и оптимизатор сохраняют совместимость, пока отдельный контракт не проверен. Значения cache при отказе помечаются последним снимком; времена чтения/записи остаются раздельными.
После nullable блока: URL устройства/вкладки/поиска с back/reload и отсутствующим устройством; при переходе по истории во время write обязательно освободить lock прежней операции только в соответствующем контексте. Затем одна desktop/mobile схема и скриншоты; не считать OSPF принятым до этих проверок.
+
+## 10.18. Nullable BFD и соседство
+
+Строгий каталог сохраняет null/0 отдельно для BFD timers/counters/multiplier и priority/stateChanges соседа. Actual TX не подменяется desired TX; units/clock duration проверяются строго. 15 HTTP E2E, raw backend fixtures, TS/backend build прошли. Далее interface/instance/route nullable contract и readiness. [RouterOS BFD](https://help.mikrotik.com/docs/spaces/ROS/pages/191299691/BFD) и [OSPF](https://help.mikrotik.com/docs/spaces/ROS/pages/331612216/routing%2Bospf) — источники семантики.
diff --git a/lib/ospf-response.ts b/lib/ospf-response.ts
index 3d4250d1..948e1fa8 100644
--- a/lib/ospf-response.ts
+++ b/lib/ospf-response.ts
@@ -3,10 +3,10 @@ import { vxlanServersSchema } from "@/lib/vxlan-response"
const counter = z.number().int().nonnegative()
const base = z.object({ id: z.string().trim().min(1), serverId: z.number().int().positive(), serverName: z.string(), serverSite: z.string(), serverCountry: z.string().default("UN") })
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, priority: counter })),
+ 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, type: z.string(), disabled: z.boolean(), inactive: z.boolean(), priority: counter, helloInterval: counter, deadInterval: counter, useBfd: z.boolean() })),
instances: z.array(base.extend({ name: z.string(), routerId: z.string(), version: counter, disabled: z.boolean(), inactive: z.boolean(), 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, txInterval: counter, rxInterval: counter, holdTime: counter, packetsRx: counter, packetsTx: counter, stateChanges: counter })),
+ 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, 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 => {
diff --git a/tests/e2e/ospf-values.spec.ts b/tests/e2e/ospf-values.spec.ts
new file mode 100644
index 00000000..77548f6e
--- /dev/null
+++ b/tests/e2e/ospf-values.spec.ts
@@ -0,0 +1,25 @@
+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 }
+ 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.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(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()
+ 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")
+ 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()
+ await expect(cells.last()).toHaveText("0")
+})