Files
MikrotikManager/backend/src/routes/ospf.ts
T
DenozordecandCursor c6c859a495
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m2s
Docker images / frontend-image (push) Successful in 3m23s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m47s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
perf(db): сжать схему PostgreSQL 18 и повторно загрузить SQLite
При первом рестарте wipe всех таблиц и импорт из SQLite в компактную схему. Retention через DROP PARTITION, lz4 и AIO worker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 10:54:36 +07:00

724 lines
26 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,
OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionRead,
} from "../types/server.js"
import { z } from "zod"
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] = await Promise.all([
client.getOspfNeighbors(),
client.getOspfAreas(),
client.getOspfInterfaceTemplates(),
client.getOspfInstances(),
client.getBfdSessions().catch(() => [] as RosBfdSession[]), // BFD is optional
])
return { neighbors, areas, ifaceTemplates, instances, bfdSessions }
}
// ── BFD parser ────────────────────────────────────────────────────────────────
function parseBfdSessions(server: ServerRow, raw: RosBfdSession[]): 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: 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,
}
})
}
// ── 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",
}
})
}
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 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 isRefInterfaceName(name: string): boolean {
return /^\(ref\s+\*.+\)$/.test(name.trim())
}
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 = {
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, areas, instances, neighbors } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
const parsed = parseInterfaces(server, ifaceTemplates, areas, instances, areaMap)
const editable = parsed.filter((i) => !i.disabled && !isRefInterfaceName(i.interface))
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))
const out = new Map<string, ServerRow["id"]>()
const neededLeft = new Set(needed)
for (const s of enabledServers) {
if (neededLeft.size === 0) break
try {
const c = MikrotikClient.fromServer(s)
const inst = await c.getOspfInstances()
for (const i of inst) {
const rid = String(i["router-id"] ?? "").trim()
if (!rid) continue
if (neededLeft.has(rid)) {
out.set(rid, s.id)
neededLeft.delete(rid)
}
}
} catch {
// ignore per-server errors, preview should still work
}
}
return out
}
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 =
bestProbe?.lastTxAvgMbps != null
? Math.max(1, Math.round(bestProbe.lastTxAvgMbps))
: speed.dlMbps
const ulMbps =
bestProbe?.lastRxAvgMbps != null
? Math.max(1, Math.round(bestProbe.lastRxAvgMbps))
: speed.ulMbps
return {
id: iface.id,
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 ──────────────────────────────────────────────────────────────
const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// 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 } = 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),
}
} catch {
return { neighbors: [], interfaces: [], instances: [], bfdSessions: [] }
}
}),
)
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),
})
})
// 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 } = 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),
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 = (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 pingWeight = req.body.pingWeight
const ranked = await 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 = (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 pingWeight = req.body.pingWeight
const ranked = await buildOspfOptimizationPlan(server, pingWeight)
const client = 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) {
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 ospfRoutes