fix(api): сохранять неизвестные показатели BFD и OSPF

This commit is contained in:
Denozordec committed 2026-09-30 19:42:07 +07:00
1 parent 47dcd89b3c
commit e3791f3496
13 files changed
+136 -44

No files matched your search

+15 -20
View File
@@ -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 (
<div className="flex flex-col gap-4">
@@ -1118,8 +1112,9 @@ function BfdTab({ sessions, dataIncomplete }: { sessions: BfdSession[]; dataInco
<div className="flex items-center gap-4 flex-wrap px-1">
<p className="text-xs text-muted-foreground">
BFD обнаруживает сбои быстрее OSPF Hello/Dead таймеров.
Tx&nbsp;/&nbsp;Rx — интервалы отправки и приёма контрольных пакетов.
{!dataIncomplete && totalPkts > 0 && <span className="ml-2 tabular-nums">Всего получено: {totalPkts.toLocaleString()} пакетов.</span>}
{!dataIncomplete && totalPkts === null && <span> Общее число полученных пакетов неизвестно.</span>}
TX факт — наблюдаемый интервал отправки; RX минимум — требуемый минимальный интервал.
{!dataIncomplete && totalPkts !== null && <span className="ml-2 tabular-nums">Всего получено: {totalPkts.toLocaleString()} пакетов.</span>}
</p>
</div>
)}
+1 -1
View File
@@ -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"
},
+13 -1
View File
@@ -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")
+12 -1
View File
@@ -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<string, string>) {
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 {
+5 -2
View File
@@ -1,8 +1,11 @@
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 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<MikrotikClient, "getOspfNeighbors" | "getOspfAreas" | "getOspfInterfaceTemplates" | "getOspfInstances" | "getBfdSessions" | "getIpRoutes">) {
@@ -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")
@@ -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<string, number> = { 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<string>()
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
}
+14 -14
View File
@@ -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: () => <span className="text-xs font-medium text-muted-foreground">Tx / Rx</span>,
header: () => <span className="text-xs font-medium text-muted-foreground">TX факт / RX минимум</span>,
enableSorting: false,
cell: ({ row }) => {
const b = row.original
@@ -209,7 +209,7 @@ function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
accessorKey: "multiplier",
header: ({ column }) => <DataGridSortHeader column={column} title="Mult" />,
cell: ({ row }) => (
<span className="font-mono tabular-nums text-center block">{row.original.multiplier}</span>
<span className="font-mono tabular-nums text-center block">{row.original.multiplier ?? "—"}</span>
),
meta: {
headerTitle: "Mult",
@@ -225,7 +225,7 @@ function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
),
cell: ({ row }) => (
<span className="font-mono tabular-nums text-right text-muted-foreground block">
{row.original.packetsRx.toLocaleString()}
{row.original.packetsRx?.toLocaleString() ?? "—"}
</span>
),
meta: {
@@ -242,7 +242,7 @@ function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
),
cell: ({ row }) => (
<span className="font-mono tabular-nums text-right text-muted-foreground block">
{row.original.packetsTx.toLocaleString()}
{row.original.packetsTx?.toLocaleString() ?? "—"}
</span>
),
meta: {
@@ -258,7 +258,7 @@ function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
<DataGridSortHeader column={column} title="Переходы" className="ml-auto" />
),
cell: ({ row }) => (
<span className="font-mono tabular-nums text-right block">{row.original.stateChanges}</span>
<span className="font-mono tabular-nums text-right block">{row.original.stateChanges ?? "—"}</span>
),
meta: {
headerTitle: "Переходы",
@@ -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 }) => <DataGridSortHeader column={column} title="Prio" />,
cell: ({ row }) => (
<span className="text-center font-mono block">{row.original.priority}</span>
<span className="text-center font-mono block">{row.original.priority ?? "—"}</span>
),
meta: {
headerTitle: "Prio",
+8 -1
View File
@@ -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 остаётся совместимым. Изменений сетевых правил нет.
+4
View File
@@ -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) — источники семантики.
+2 -2
View File
@@ -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 => {
+25
View File
@@ -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")
})