Files
MikrotikManager/backend/src/routes/ospf.ts
T

774 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { and, desc, eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { serverSnapshots, servers, trafficSamples, uptimeSpeedProbes } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
import type {
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession, RosIpRoute,
OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, OspfRouteRead, BfdSessionRead,
} from "../types/server.js"
import { parseOspfGateway, parseOspfRouteType, parseOspfCatalogRouteType } from "../services/ospf-route-parse.js"
import { z } from "zod"
import {resolveOptimizerRouterOwners} from "../services/ospf-optimizer-identity.js"
import {readOspfOptimizerObserved,readOspfOptimizerModelSpeeds} from "../services/ospf-optimizer-metrics.js"
import type {OspfOptimizerObserved} from "@mmapp/contracts"
import {readEditableOspfTemplates,validateOspfOptimizationPlan} from "../services/ospf-optimizer-plan.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
type TrafficSampleRow = typeof trafficSamples.$inferSelect
type SpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect
const OspfOptimizeBodySchema = z.object({
pingWeight: z.number().int().min(0).max(100).default(60),
})
// ── helpers ───────────────────────────────────────────────────────────────────
/** Parse RouterOS duration string like "1h30m17s", "40s", "10m" → seconds */
function parseDuration(s: string | undefined): number {
if (!s) return 0
let total = 0
for (const m of s.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) {
const n = parseInt(m[1])
switch (m[2]) {
case "w": total += n * 604800; break
case "d": total += n * 86400; break
case "h": total += n * 3600; break
case "m": total += n * 60; break
case "s": total += n; break
}
}
return total
}
/** Build area-name → area-id lookup from a list of raw areas */
function buildAreaMap(areas: RosOspfArea[]): Map<string, string> {
const map = new Map<string, string>()
for (const a of areas) {
map.set(a.name, a["area-id"] ?? "0.0.0.0")
}
return map
}
/** Parse "200ms" | "1s" | "500ms" → milliseconds */
function parseMs(s: string | undefined): number {
if (!s) return 0
const ms = s.match(/^(\d+)ms$/)
if (ms) return parseInt(ms[1])
const sec = s.match(/^(\d+)s$/)
if (sec) return parseInt(sec[1]) * 1000
return parseInt(s) || 0
}
/** Normalize BFD state: "up" → "Up", "down" → "Down", etc. */
function normalizeBfdState(state: string | undefined): string {
const map: Record<string, string> = {
up: "Up", down: "Down", init: "Init", admindown: "AdminDown",
}
return map[(state ?? "").toLowerCase()] ?? (state ?? "Down")
}
/** Extract IP and interface from RouterOS address format "10.0.0.1%eth0" */
function parseAddrIface(addr: string): { ip: string; iface: string } {
const [ip, iface = ""] = addr.split("%")
return { ip, iface }
}
/** Fetch all OSPF + BFD data for one server */
async function fetchServerOspf(server: ServerRow) {
const client = MikrotikClient.fromServer(server)
const [neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes] = await Promise.all([
client.getOspfNeighbors(),
client.getOspfAreas(),
client.getOspfInterfaceTemplates(),
client.getOspfInstances(),
client.getBfdSessions().catch(() => [] as RosBfdSession[]), // BFD is optional
client.getIpRoutes().catch(() => [] as RosIpRoute[]),
])
return { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes }
}
// ── BFD parser ────────────────────────────────────────────────────────────────
export function parseBfdSessions(server: ServerRow, raw: RosBfdSession[], strictState = false): BfdSessionRead[] {
return raw.map((s, idx) => {
const local = parseAddrIface(s["local-address"])
const remote = parseAddrIface(s["remote-address"])
// Prefer interface from local-address; fallback to remote-address suffix
const iface = local.iface || remote.iface
return {
id: s[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
localAddr: local.ip,
remoteAddr: remote.ip,
interface: iface,
state: strictState && !s["state"]?.trim() ? "Unknown" : normalizeBfdState(s["state"]),
uptime: s["uptime"] ?? null,
multihop: s["multihop"] === "true",
multiplier: parseInt(s["multiplier"] ?? "3") || 3,
txInterval: parseMs(s["actual-tx-interval"] ?? s["desired-tx-interval"]),
rxInterval: parseMs(s["required-min-rx"]),
holdTime: parseMs(s["hold-time"]),
packetsRx: parseInt(s["packets-rx"] ?? "0") || 0,
packetsTx: parseInt(s["packets-tx"] ?? "0") || 0,
stateChanges: parseInt(s["state-changes"] ?? "0") || 0,
}
})
}
export function parseBfdCatalogSessions(server: ServerRow, raw: RosBfdSession[]) {
return parseBfdSessions(server, raw, true).map((row, index) => {
const source = raw[index]
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>) {
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(
server: ServerRow,
neighbors: RosOspfNeighbor[],
areaMap: Map<string, string>,
): OspfNeighborRead[] {
return neighbors.map((n, idx) => ({
id: n[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
address: n.address,
routerId: n["router-id"],
instance: n.instance,
area: n.area,
areaId: areaMap.get(n.area) ?? n.area,
interface: n.interface,
state: n.state,
uptime: n.adjacency ?? null,
stateChanges: parseInt(n["state-changes"] ?? "0") || 0,
priority: parseInt(n.priority ?? "1") || 1,
}))
}
function parseInterfaces(
server: ServerRow,
templates: RosOspfInterfaceTemplate[],
areas: RosOspfArea[],
instances: RosOspfInstance[],
areaMap: Map<string, string>,
): OspfInterfaceRead[] {
// Build instance-id → instance name map (for interface-template instance-id field)
const instanceIdMap = new Map<string, string>()
instances.forEach((inst, i) => { instanceIdMap.set(String(i), inst.name) })
return templates.map((t, idx) => {
// Resolve interface name: may be a "*ID" reference (RouterOS internal ID)
const ifaceName = (t.interfaces ?? "").startsWith("*")
? `(ref ${t.interfaces})`
: (t.interfaces ?? "—")
return {
id: t[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
instance: instanceIdMap.get(t["instance-id"] ?? "") ?? t["instance-id"] ?? "",
area: t.area,
areaId: areaMap.get(t.area) ?? t.area,
interface: ifaceName,
cost: parseInt(t.cost ?? "10") || 10,
type: t.type ?? "broadcast",
disabled: t.disabled === "true",
inactive: t.inactive === "true",
priority: parseInt(t.priority ?? "1") || 1,
helloInterval: parseDuration(t["hello-interval"]),
deadInterval: parseDuration(t["dead-interval"]),
useBfd: t["use-bfd"] === "true",
}
})
}
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, type: parseOspfCatalogRouteType(source) ?? "Unknown", cost: observedInteger(source["ospf-metric"], 0, 4294967295) }))
})
}
function parseInstances(
server: ServerRow,
instances: RosOspfInstance[],
): OspfInstanceRead[] {
return instances.map((inst, idx) => ({
id: inst[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
name: inst.name,
routerId: inst["router-id"],
version: parseInt(inst.version ?? "2") || 2,
disabled: inst.disabled === "true",
inactive: inst.inactive === "true",
redistribute: inst.redistribute ?? "",
}))
}
function parseOspfRoutes(server: ServerRow, routes: RosIpRoute[]): OspfRouteRead[] {
const out: OspfRouteRead[] = []
for (const [idx, r] of routes.entries()) {
const type = parseOspfRouteType(r)
if (!type) continue
const { nextHop, via } = parseOspfGateway(r)
const metric = parseInt(r["ospf-metric"] ?? r.distance ?? "0") || 0
out.push({
id: r[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
destination: r["dst-address"] ?? "",
type,
cost: metric,
nextHop,
via,
area: r["ospf-area"] ?? "",
})
}
return out
}
function calcRouteScore(pingMs: number, dlMbps: number, ulMbps: number, pingWeight: number) {
const pingScore = Math.max(0, 100 - pingMs * 0.6)
const speedScore = Math.min(100, (dlMbps + ulMbps) / 18)
const w = pingWeight / 100
return Math.round(w * pingScore + (1 - w) * speedScore)
}
async function getLatestSnapshotLatencyMs(serverId: number): Promise<number> {
const latest = await db
.select()
.from(serverSnapshots)
.where(eq(serverSnapshots.serverId, serverId))
.orderBy(desc(serverSnapshots.polledAt))
.limit(1)
return latest.length > 0 ? Math.max(1, Math.round(latest[0].latencyMs ?? 100)) : 100
}
async function latestTrafficByInterface(serverId: number): Promise<Map<string, TrafficSampleRow>> {
const rows = await db
.select()
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
const map = new Map<string, TrafficSampleRow>()
for (const row of rows) {
const prev = map.get(row.interfaceName)
if (!prev || row.sampledAt > prev.sampledAt) map.set(row.interfaceName, row)
}
return map
}
function normalizeSpeedMbps(row: TrafficSampleRow | undefined): { dlMbps: number; ulMbps: number } {
if (!row) return { dlMbps: 10, ulMbps: 10 }
const dl = Math.max(1, Math.round((row.rxBps ?? 0) / 1_000_000))
const ul = Math.max(1, Math.round((row.txBps ?? 0) / 1_000_000))
return { dlMbps: dl, ulMbps: ul }
}
function parsePingTimeMs(raw: string | undefined): number | null {
if (!raw) return null
const s = String(raw).trim().toLowerCase().replace(",", ".")
const us = s.match(/^(\d+(?:\.\d+)?)\s*us$/)
if (us) return Number.parseFloat(us[1]) / 1000
const ms = s.match(/^(\d+(?:\.\d+)?)\s*ms$/)
if (ms) return Number.parseFloat(ms[1])
const sec = s.match(/^(\d+(?:\.\d+)?)\s*s$/)
if (sec) return Number.parseFloat(sec[1]) * 1000
const clock = s.match(/^(\d+):(\d+):(\d+(?:\.\d+)?)$/)
if (clock) {
const h = Number.parseFloat(clock[1])
const m = Number.parseFloat(clock[2])
const sc = Number.parseFloat(clock[3])
return (h * 3600 + m * 60 + sc) * 1000
}
return null
}
function avgPingMsFromResults(rows: Array<{ time?: string; status?: string }>): number | null {
const ok = rows.filter((r) => r.time && String(r.status ?? "").toLowerCase() !== "timeout")
const vals = ok
.map((r) => parsePingTimeMs(r.time))
.filter((v): v is number => v != null && Number.isFinite(v))
if (!vals.length) return null
return Math.max(1, Math.round(vals.reduce((a, b) => a + b, 0) / vals.length))
}
async function measurePingByInterface(
client: MikrotikClient,
ifaceName: string,
targetAddr: string | undefined,
fallbackMs: number,
): Promise<number> {
if (!targetAddr) return fallbackMs
try {
const rows = await client.ping(targetAddr, 2, ifaceName, { interval: "0.2s" })
return avgPingMsFromResults(rows) ?? fallbackMs
} catch {
return fallbackMs
}
}
type OspfRankedInterface = {
observed?: OspfOptimizerObserved
id: string
interface: string
currentCost: number
pingMs: number
dlMbps: number
ulMbps: number
score: number
optimalCost: number
}
async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number): Promise<OspfRankedInterface[]> {
const { ifaceTemplates, neighbors } = await fetchServerOspf(server)
const editable = readEditableOspfTemplates(ifaceTemplates)
const localLatencyMs = await getLatestSnapshotLatencyMs(server.id)
const latestTraffic = await latestTrafficByInterface(server.id)
// 1) Сопоставляем remote OSPF router-id -> сервер из каталога (чтобы взять унифицированный ping как в карте /route-optimizer).
const neededRouterIds = new Set(neighbors.map((n) => String(n["router-id"] ?? "")).filter((x) => x.length > 0))
async function buildRouterIdToServerMap(needed: Set<string>): Promise<Map<string, ServerRow["id"]>> {
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
return resolveOptimizerRouterOwners(enabledServers,needed,async id=>{
const target=enabledServers.find(server=>server.id===id)!
return MikrotikClient.fromServer(target).getOspfInstances()
})
}
const routerIdToServerId = await buildRouterIdToServerMap(neededRouterIds)
// 2) Сначала выбираем remote serverId для каждого OSPF интерфейса (по neighbor router-id).
function pickBestNeighborForInterface(iface: string) {
const cand = neighbors.filter((n) => n.interface === iface)
if (!cand.length) return null
const rank = (st: string) => (st === "Full" ? 100 : st === "2-Way" ? 60 : 30)
return cand.sort((a, b) => rank(b.state) - rank(a.state))[0] ?? null
}
const remoteServerIds = new Set<number>()
const remoteForIface = new Map<string, number>()
for (const iface of editable) {
const n = pickBestNeighborForInterface(iface.interface)
if (!n) continue
const remoteServerId = routerIdToServerId.get(String(n["router-id"] ?? "").trim())
if (remoteServerId != null) {
remoteServerIds.add(remoteServerId)
remoteForIface.set(iface.interface, remoteServerId)
}
}
// 3) Предгружаем uptimeSpeedProbes: оттуда берём и ping, и скорость (единый источник как у карты).
const allSourceProbes = (await db
.select()
.from(uptimeSpeedProbes)
.where(eq(uptimeSpeedProbes.srcServerId, server.id)))
.filter((r: SpeedProbeRow) => r.enabled !== false)
const speedProbesByDest = new Map<number, SpeedProbeRow[]>()
for (const p of allSourceProbes) {
const arr = speedProbesByDest.get(p.dstServerId) ?? []
arr.push(p)
speedProbesByDest.set(p.dstServerId, arr)
}
function normIface(s: string) {
return s.trim().toLowerCase()
}
function pickBestSpeedProbe(probes: SpeedProbeRow[], ifaceName: string): SpeedProbeRow | undefined {
if (probes.length === 0) return undefined
const scoreProbeForIface = (p: SpeedProbeRow, targetIface: string): number => {
const got = normIface(p.srcInterface)
const target = normIface(targetIface)
// Сверяемся с /gre: если у пробы есть srcInterface и у OSPF iface есть имя,
// допускаем только точное совпадение интерфейса.
if (got && target && got !== target) return -1
const ifaceScore =
got && target && got === target ? 100
: (!got && target) ? 35
: (!target && got) ? 15
: 5
const freshness =
p.lastPingAt ? Math.max(0, 20 - Math.floor((Date.now() - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0
return ifaceScore
+ (p.lastPingRttMs != null ? 25 : 0)
+ (p.lastTxAvgMbps != null ? 12 : 0)
+ (p.lastRxAvgMbps != null ? 12 : 0)
+ freshness
}
let best: SpeedProbeRow | undefined
let bestScore = -1
for (const p of probes) {
const s = scoreProbeForIface(p, ifaceName)
if (s > bestScore) {
bestScore = s
best = p
}
}
return best
}
function assignProbesUniquely(
ifaceNames: string[],
probes: SpeedProbeRow[],
): Map<string, SpeedProbeRow> {
const assigned = new Map<string, SpeedProbeRow>()
if (ifaceNames.length === 0 || probes.length === 0) return assigned
const scoreProbeForIface = (p: SpeedProbeRow, ifaceName: string): number => {
const got = normIface(p.srcInterface)
const target = normIface(ifaceName)
if (got && target && got !== target) return -1
const ifaceScore =
got && target && got === target ? 100
: (!got && target) ? 35
: (!target && got) ? 15
: 5
const freshness =
p.lastPingAt ? Math.max(0, 20 - Math.floor((Date.now() - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0
return ifaceScore
+ (p.lastPingRttMs != null ? 25 : 0)
+ (p.lastTxAvgMbps != null ? 12 : 0)
+ (p.lastRxAvgMbps != null ? 12 : 0)
+ freshness
}
const leftIfaces = new Set(ifaceNames)
const usedProbeIds = new Set<string>()
while (leftIfaces.size > 0) {
let bestIface: string | null = null
let bestProbe: SpeedProbeRow | null = null
let bestScore = -1
for (const iface of leftIfaces) {
for (const p of probes) {
if (usedProbeIds.has(p.id)) continue
const s = scoreProbeForIface(p, iface)
if (s > bestScore) {
bestScore = s
bestIface = iface
bestProbe = p
}
}
}
if (!bestIface || !bestProbe) break
assigned.set(bestIface, bestProbe)
leftIfaces.delete(bestIface)
usedProbeIds.add(bestProbe.id)
}
// Если проб меньше чем интерфейсов — добираем лучшими совпадениями (reuse допустим).
for (const iface of leftIfaces) {
const fallback = pickBestSpeedProbe(probes, iface)
if (fallback) assigned.set(iface, fallback)
}
return assigned
}
const assignedProbeByIface = new Map<string, SpeedProbeRow>()
const ifacesByDst = new Map<number | null, string[]>()
for (const iface of editable) {
const dst = remoteForIface.get(iface.interface) ?? null
const arr = ifacesByDst.get(dst) ?? []
arr.push(iface.interface)
ifacesByDst.set(dst, arr)
}
for (const [dst, ifaceList] of ifacesByDst) {
const probesByDst = dst != null ? speedProbesByDest.get(dst) ?? [] : []
const pool = probesByDst.length > 0 ? probesByDst : allSourceProbes
const assigned = assignProbesUniquely(ifaceList, pool)
for (const [iface, probe] of assigned.entries()) assignedProbeByIface.set(iface, probe)
}
return (await Promise.all(editable.map(async (iface) => {
const t = latestTraffic.get(iface.interface)
const speed = normalizeSpeedMbps(t)
const dstServerId = remoteForIface.get(iface.interface)
const bestProbe = assignedProbeByIface.get(iface.interface)
const pingMs =
bestProbe?.lastPingRttMs != null
? Math.max(1, Math.round(bestProbe.lastPingRttMs))
: dstServerId != null
? Math.min(995, Math.round(localLatencyMs + await getLatestSnapshotLatencyMs(dstServerId)))
: localLatencyMs
const {dlMbps,ulMbps} = readOspfOptimizerModelSpeeds(bestProbe,speed)
return {
id: iface.id,
observed: readOspfOptimizerObserved(bestProbe,t),
interface: iface.interface,
currentCost: iface.cost,
pingMs,
dlMbps,
ulMbps,
score: calcRouteScore(pingMs, dlMbps, ulMbps, pingWeight),
optimalCost: 0,
}
}))).sort((a, b) => b.score - a.score || a.interface.localeCompare(b.interface))
.map((row, idx) => ({ ...row, optimalCost: (idx + 1) * 10 }))
}
// ── route plugin ──────────────────────────────────────────────────────────────
interface OspfCatalogDependencies {
listEnabledServers: () => Promise<ServerRow[]>
readCatalog: (server: ServerRow) => Promise<OspfCatalogCollections>
getOptimizationServer?: (id:number)=>Promise<ServerRow | undefined>
buildOptimizationPlan?: (server:ServerRow,weight:number)=>Promise<OspfRankedInterface[]>
setTemplateCost?: (server:ServerRow,id:string,cost:number)=>Promise<void>
}
const catalogDefaults: OspfCatalogDependencies = {
listEnabledServers: () => db.select().from(servers).where(eq(servers.enabled, true)),
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: 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 {
return async (app) => {
app.get("/ospf/catalog", async (_req, reply) => reply.send(await pollOspfCatalog(await dependencies.listEnabledServers(), dependencies.readCatalog)))
// GET /api/ospf/neighbors — aggregate OSPF neighbors from ALL enabled servers
app.get("/ospf/neighbors", async (_req, reply) => {
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
allServers.map(async (server) => {
try {
const { neighbors, areas } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return parseNeighbors(server, neighbors, areaMap)
} catch {
return []
}
}),
)
return reply.send(results.flat())
})
// GET /api/ospf/interfaces — aggregate OSPF interface templates from ALL enabled servers
app.get("/ospf/interfaces", async (_req, reply) => {
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
allServers.map(async (server) => {
try {
const { ifaceTemplates, areas, instances } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return parseInterfaces(server, ifaceTemplates, areas, instances, areaMap)
} catch {
return []
}
}),
)
return reply.send(results.flat())
})
// GET /api/ospf/instances — aggregate OSPF instances from ALL enabled servers
app.get("/ospf/instances", async (_req, reply) => {
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
allServers.map(async (server) => {
try {
const { instances } = await fetchServerOspf(server)
return parseInstances(server, instances)
} catch {
return []
}
}),
)
return reply.send(results.flat())
})
// GET /api/ospf/all — single round-trip: neighbors + interfaces + instances + BFD
app.get("/ospf/all", async (_req, reply) => {
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const perServer = await Promise.all(
allServers.map(async (server) => {
try {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } = await fetchServerOspf(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),
routes: parseOspfRoutes(server, ipRoutes),
}
} catch {
return { neighbors: [], interfaces: [], instances: [], bfdSessions: [], routes: [] }
}
}),
)
return reply.send({
neighbors: perServer.flatMap(r => r.neighbors),
interfaces: perServer.flatMap(r => r.interfaces),
instances: perServer.flatMap(r => r.instances),
bfdSessions: perServer.flatMap(r => r.bfdSessions),
routes: perServer.flatMap(r => r.routes),
})
})
// GET /api/bfd/sessions — BFD sessions only (for direct access)
app.get("/bfd/sessions", async (_req, reply) => {
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
allServers.map(async (server) => {
try {
const client = MikrotikClient.fromServer(server)
const raw = await client.getBfdSessions()
return parseBfdSessions(server, raw)
} catch {
return []
}
}),
)
return reply.send(results.flat())
})
// GET /api/servers/:id/ospf — single server OSPF + BFD data
app.get("/servers/:id/ospf", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const params = req.params as ServerIdParams
const server = (await db
.select().from(servers)
.where(eq(servers.id, params.id))
.limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return reply.send({
neighbors: parseNeighbors(server, neighbors, areaMap),
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
instances: parseInstances(server, instances),
bfdSessions: parseBfdSessions(server, bfdSessions),
routes: parseOspfRoutes(server, ipRoutes),
areas: areas.map(a => ({ name: a.name, areaId: a["area-id"] ?? "0.0.0.0", type: a.type, disabled: a.disabled === "true", inactive: a.inactive === "true", instance: a.instance })),
})
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
})
// POST /api/servers/:id/ospf/optimize — compute + apply optimal OSPF cost for active templates
app.post(
"/servers/:id/ospf/optimize/preview",
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
async (req, reply) => {
const params = req.params as ServerIdParams
const server = dependencies.getOptimizationServer ? await dependencies.getOptimizationServer(params.id) : (await db.select().from(servers).where(eq(servers.id,params.id)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({error:"Устройство OSPF отключено"})
try {
const pingWeight = req.body.pingWeight
const ranked = validateOspfOptimizationPlan(await (dependencies.buildOptimizationPlan ?? buildOspfOptimizationPlan)(server, pingWeight))
const changed = ranked.filter((r) => r.currentCost !== r.optimalCost)
return reply.send({
serverId: server.id,
serverName: server.name || server.host,
pingWeight,
interfacesTotal: ranked.length,
changedCount: changed.length,
unchangedCount: ranked.length - changed.length,
interfaces: ranked,
changes: changed,
})
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
},
)
app.post(
"/servers/:id/ospf/optimize",
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
async (req, reply) => {
const params = req.params as ServerIdParams
const server = dependencies.getOptimizationServer ? await dependencies.getOptimizationServer(params.id) : (await db.select().from(servers).where(eq(servers.id,params.id)).limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({error:"Устройство OSPF отключено"})
try {
const pingWeight = req.body.pingWeight
const ranked = validateOspfOptimizationPlan(await (dependencies.buildOptimizationPlan ?? buildOspfOptimizationPlan)(server, pingWeight))
const client=dependencies.setTemplateCost?null:MikrotikClient.fromServer(server)
const changed = ranked.filter((r) => r.currentCost !== r.optimalCost)
const applied: Array<{ interface: string; from: number; to: number }> = []
for (const item of changed) {
if(dependencies.setTemplateCost) await dependencies.setTemplateCost(server,item.id,item.optimalCost)
else await client!.setOspfInterfaceTemplateCost(item.id,item.optimalCost)
applied.push({ interface: item.interface, from: item.currentCost, to: item.optimalCost })
}
return reply.send({
serverId: server.id,
serverName: server.name || server.host,
pingWeight,
optimizedCount: changed.length,
applied,
interfaces: ranked,
})
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
},
)
}
}
export default createOspfRoutes()