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
При первом рестарте wipe всех таблиц и импорт из SQLite в компактную схему. Retention через DROP PARTITION, lz4 и AIO worker. Co-authored-by: Cursor <cursoragent@cursor.com>
724 lines
26 KiB
TypeScript
724 lines
26 KiB
TypeScript
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
|