fix(api): показывать подтверждённые метрики OSPF

This commit is contained in:
Denozordec committed 2026-09-30 19:49:47 +07:00
1 parent e3791f3496
commit af05ad5cda
13 files changed
+120 -55

No files matched your search

+18 -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, parseBfdCatalogSessions, parseCatalogNeighbors } from "./ospf.js"
import { createOspfRoutes, parseBfdSessions, parseBfdCatalogSessions, parseCatalogNeighbors, parseCatalogInterfaces, parseCatalogInstances, parseCatalogRoutes } 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: "Второй" }]
@@ -49,3 +49,20 @@ const neighborZero = parseCatalogNeighbors(bfdServer, [{ ...neighborSource, prio
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")
const rawIface = { ".id": "*1", area: "backbone", interfaces: "ether1", disabled: "false", inactive: "false" }
const missingIface = parseCatalogInterfaces(bfdServer, [rawIface], [], [], new Map())[0]
for (const field of ["cost", "priority", "type", "helloInterval", "deadInterval", "useBfd"] as const) assert.equal(missingIface[field], null)
const zeroIface = parseCatalogInterfaces(bfdServer, [{ ...rawIface, cost: "0", priority: "0", type: "ptp", "hello-interval": "0s", "dead-interval": "1s500ms", "use-bfd": "no" }], [], [], new Map())[0]
assert.equal(zeroIface.cost, 0); assert.equal(zeroIface.priority, 0); assert.equal(zeroIface.helloInterval, 0); assert.equal(zeroIface.deadInterval, 1.5); assert.equal(zeroIface.useBfd, false)
assert.equal(parseCatalogInterfaces(bfdServer, [{ ...rawIface, disabled: "junk", cost: "10x" }], [], [], new Map())[0].disabled, null)
const rawInstance = { ".id": "*1", name: "default", disabled: "false", inactive: "false" }
const missingInstance = parseCatalogInstances(bfdServer, [rawInstance])[0]
assert.equal(missingInstance.version, null); assert.equal(missingInstance.routerId, null)
assert.equal(parseCatalogInstances(bfdServer, [{ ...rawInstance, version: "3", "router-id": "main" }])[0].version, 3)
const rawRoute = { ".id": "*1", "dst-address": "192.0.2.0/24", ospf: "true", distance: "110" }
assert.equal(parseCatalogRoutes(bfdServer, [rawRoute])[0].cost, null)
assert.equal(parseCatalogRoutes(bfdServer, [{ ...rawRoute, "ospf-metric": "0" }])[0].cost, 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")
+19 -2
View File
@@ -11,7 +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 { observedInteger, observedDurationMs, observedBoolean } from "../services/ospf-observed-values.js"
import { pollOspfCatalog, readOspfCatalogRaw, type OspfCatalogCollections } from "../services/ospf-catalog-poll.js"
type ServerRow = typeof servers.$inferSelect
@@ -195,6 +195,23 @@ function parseInterfaces(
})
}
export function parseCatalogInterfaces(server: ServerRow, raw: RosOspfInterfaceTemplate[], areas: RosOspfArea[], instances: RosOspfInstance[], areaMap: Map<string, string>) {
return parseInterfaces(server, raw, areas, instances, areaMap).map((row, index) => {
const source = raw[index]
const helloMs = observedDurationMs(source["hello-interval"]), deadMs = observedDurationMs(source["dead-interval"])
return { ...row, cost: observedInteger(source.cost, 0, 65535), priority: observedInteger(source.priority, 0, 255), type: source.type?.trim() || null, disabled: observedBoolean(source.disabled), inactive: observedBoolean(source.inactive), useBfd: observedBoolean(source["use-bfd"]), helloInterval: helloMs === null ? null : helloMs / 1000, deadInterval: deadMs === null ? null : deadMs / 1000 }
})
}
export function parseCatalogInstances(server: ServerRow, raw: RosOspfInstance[]) {
return parseInstances(server, raw).map((row, index) => ({ ...row, version: ["2", "3"].includes(raw[index].version) ? Number(raw[index].version) : null, routerId: raw[index]["router-id"]?.trim() || null, disabled: observedBoolean(raw[index].disabled), inactive: observedBoolean(raw[index].inactive) }))
}
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) }))
})
}
function parseInstances(
server: ServerRow,
instances: RosOspfInstance[],
@@ -565,7 +582,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: parseCatalogNeighbors(server, neighbors, areaMap), interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap), instances: parseInstances(server, instances), bfdSessions: parseBfdCatalogSessions(server, bfdSessions), routes: parseOspfRoutes(server, ipRoutes) }
return { neighbors: parseCatalogNeighbors(server, neighbors, areaMap), interfaces: parseCatalogInterfaces(server, ifaceTemplates, areas, instances, areaMap), instances: parseCatalogInstances(server, instances), bfdSessions: parseBfdCatalogSessions(server, bfdSessions), routes: parseCatalogRoutes(server, ipRoutes) }
},
}
export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalogDefaults): FastifyPluginAsyncZod {
+5 -2
View File
@@ -3,9 +3,12 @@ import type { OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionR
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 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 interface OspfCatalogCollections {
neighbors: CatalogNeighbor[]; interfaces: OspfInterfaceRead[]; instances: OspfInstanceRead[];
bfdSessions: CatalogBfdSession[]; routes: OspfRouteRead[];
neighbors: CatalogNeighbor[]; interfaces: CatalogInterface[]; instances: CatalogInstance[];
bfdSessions: CatalogBfdSession[]; routes: CatalogRoute[];
}
export const emptyOspfCatalog = (): OspfCatalogCollections => ({ neighbors: [], interfaces: [], instances: [], bfdSessions: [], routes: [] })
export async function readOspfCatalogRaw(client: Pick<MikrotikClient, "getOspfNeighbors" | "getOspfAreas" | "getOspfInterfaceTemplates" | "getOspfInstances" | "getBfdSessions" | "getIpRoutes">) {
@@ -1,7 +1,11 @@
import assert from "node:assert/strict"
import { observedInteger, observedDurationMs } from "./ospf-observed-values.js"
import { observedInteger, observedDurationMs, observedBoolean } 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")
for (const raw of [undefined, "", "junk"]) assert.equal(observedBoolean(raw), null)
for (const raw of ["true", "yes"]) assert.equal(observedBoolean(raw), true)
for (const raw of ["false", "no"]) assert.equal(observedBoolean(raw), false)
@@ -26,3 +26,9 @@ export function observedDurationMs(value: string | undefined): number | null {
}
return cursor === text.length && Number.isFinite(total) && total <= Number.MAX_SAFE_INTEGER ? total : null
}
export function observedBoolean(value: string | undefined): boolean | null {
if (value === "true" || value === "yes") return true
if (value === "false" || value === "no") return false
return null
}