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

+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
}