feat(bgp, vxlan, ospf): enhance server data handling and introduce new routes
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m40s
Docker images / frontend-image (push) Successful in 2m50s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m40s
Docker images / frontend-image (push) Successful in 2m50s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Added support for fetching and displaying server data in BGP, VXLAN, and OSPF pages, improving the overall user experience. - Introduced new backend routes for OSPF and VXLAN, allowing for better data management and retrieval. - Implemented mapping functions for backend server data to frontend types, ensuring consistency across components. - Enhanced the sidebar to display counts for BGP sessions, VXLAN tunnels, and containers, providing users with quick insights into their network status. - Updated tests to cover new functionalities and ensure reliability. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -29,6 +29,8 @@ import certificatesRoutes from "./routes/certificates.js"
|
||||
import systemDatabaseRoutes from "./routes/system-database.js"
|
||||
import eventsRoutes from "./routes/events.js"
|
||||
import wireguardRoutes from "./routes/wireguard.js"
|
||||
import vxlanRoutes from "./routes/vxlan.js"
|
||||
import containersRoutes from "./routes/containers.js"
|
||||
import firewallRoutes from "./routes/firewall.js"
|
||||
import usersRoutes from "./routes/users.js"
|
||||
import statisticsRoutes from "./routes/statistics.js"
|
||||
@@ -134,6 +136,8 @@ export async function buildApp(opts?: {
|
||||
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
||||
await app.register(eventsRoutes, { prefix: "/api" })
|
||||
await app.register(wireguardRoutes, { prefix: "/api" })
|
||||
await app.register(vxlanRoutes, { prefix: "/api" })
|
||||
await app.register(containersRoutes, { prefix: "/api" })
|
||||
await app.register(firewallRoutes, { prefix: "/api" })
|
||||
await app.register(usersRoutes, { prefix: "/api" })
|
||||
await app.register(statisticsRoutes, { prefix: "/api" })
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from "zod"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import {
|
||||
getEnabledServerById,
|
||||
listContainers,
|
||||
listContainersForServer,
|
||||
removeContainer,
|
||||
restartContainer,
|
||||
startContainer,
|
||||
stopContainer,
|
||||
} from "../services/containers-live.js"
|
||||
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
|
||||
|
||||
const RosIdBodySchema = z.object({
|
||||
rosId: z.string().min(1),
|
||||
})
|
||||
|
||||
type RosIdBody = z.infer<typeof RosIdBodySchema>
|
||||
type MutateFn = typeof startContainer
|
||||
|
||||
const containersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/containers", async (_req, reply) => {
|
||||
const containers = await listContainers()
|
||||
return reply.send({ containers })
|
||||
})
|
||||
|
||||
app.get("/servers/:id/containers", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const server = await getEnabledServerById(params.id)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
const containers = await listContainersForServer(server)
|
||||
return reply.send({ containers })
|
||||
})
|
||||
|
||||
function registerMutate(path: string, fn: MutateFn) {
|
||||
app.post(path, { schema: { params: ServerIdParamSchema, body: RosIdBodySchema } }, async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const body = req.body as RosIdBody
|
||||
const server = await getEnabledServerById(params.id)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
try {
|
||||
await fn(server, body.rosId)
|
||||
return reply.send({ ok: true })
|
||||
} catch (err) {
|
||||
return reply.status(502).send({
|
||||
error: err instanceof Error ? err.message : "Ошибка RouterOS",
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
registerMutate("/servers/:id/containers/start", startContainer)
|
||||
registerMutate("/servers/:id/containers/stop", stopContainer)
|
||||
registerMutate("/servers/:id/containers/restart", restartContainer)
|
||||
registerMutate("/servers/:id/containers/remove", removeContainer)
|
||||
}
|
||||
|
||||
export default containersRoutes
|
||||
@@ -6,9 +6,10 @@ 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,
|
||||
RosBfdSession, RosIpRoute,
|
||||
OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, OspfRouteRead, BfdSessionRead,
|
||||
} from "../types/server.js"
|
||||
import { parseOspfGateway, parseOspfRouteType } from "../services/ospf-route-parse.js"
|
||||
import { z } from "zod"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
@@ -74,14 +75,15 @@ function parseAddrIface(addr: string): { ip: string; iface: string } {
|
||||
/** 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([
|
||||
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 }
|
||||
return { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes }
|
||||
}
|
||||
|
||||
// ── BFD parser ────────────────────────────────────────────────────────────────
|
||||
@@ -200,6 +202,29 @@ function parseInstances(
|
||||
}))
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -584,16 +609,17 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const perServer = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
try {
|
||||
const { neighbors, areas, ifaceTemplates, instances, bfdSessions } = await fetchServerOspf(server)
|
||||
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: [] }
|
||||
return { neighbors: [], interfaces: [], instances: [], bfdSessions: [], routes: [] }
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -603,6 +629,7 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
interfaces: perServer.flatMap(r => r.interfaces),
|
||||
instances: perServer.flatMap(r => r.instances),
|
||||
bfdSessions: perServer.flatMap(r => r.bfdSessions),
|
||||
routes: perServer.flatMap(r => r.routes),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -634,13 +661,14 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
try {
|
||||
const { neighbors, areas, ifaceTemplates, instances, bfdSessions } = await fetchServerOspf(server)
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { count } from "drizzle-orm"
|
||||
import { listCertificatesFromServers } from "../services/certificates-service.js"
|
||||
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
|
||||
import { countVxlanTunnels } from "../services/vxlan-live.js"
|
||||
import { countContainers } from "../services/containers-live.js"
|
||||
import { countBgpSessions } from "../services/bgp-peers-live.js"
|
||||
import { db } from "../db/index.js"
|
||||
import {
|
||||
filterRules,
|
||||
@@ -24,9 +27,12 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const uptimeProbesTotal = await tableCount(uptimeProbes)
|
||||
const uptimeSpeedProbesTotal = await tableCount(uptimeSpeedProbes)
|
||||
const recursiveRoutesTotal = await tableCount(recursiveRoutes)
|
||||
const [certRes, wireguardTotal] = await Promise.all([
|
||||
const [certRes, wireguardTotal, bgpTotal, vxlanTotal, containersTotal] = await Promise.all([
|
||||
listCertificatesFromServers(),
|
||||
countWireGuardInterfaces().catch(() => 0),
|
||||
countBgpSessions().catch(() => 0),
|
||||
countVxlanTunnels().catch(() => 0),
|
||||
countContainers().catch(() => 0),
|
||||
])
|
||||
const certificatesTotal = certRes.certificates.length
|
||||
const usersTotal = (await listUsers()).length
|
||||
@@ -41,6 +47,9 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
certificates: certificatesTotal,
|
||||
wireguard: wireguardTotal,
|
||||
users: usersTotal,
|
||||
bgpSessions: bgpTotal,
|
||||
vxlan: vxlanTotal,
|
||||
containers: containersTotal,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import { listVxlanTunnels, listVxlanTunnelsForServer } from "../services/vxlan-live.js"
|
||||
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
|
||||
|
||||
const vxlanRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/vxlan", async (_req, reply) => {
|
||||
const tunnels = await listVxlanTunnels()
|
||||
return reply.send({ tunnels })
|
||||
})
|
||||
|
||||
app.get("/servers/:id/vxlan", { 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" })
|
||||
const tunnels = await listVxlanTunnelsForServer(server)
|
||||
return reply.send({ tunnels })
|
||||
})
|
||||
}
|
||||
|
||||
export default vxlanRoutes
|
||||
@@ -28,3 +28,16 @@ export async function fetchBgpSessionsForAlerts(): Promise<BgpSessionRead[]> {
|
||||
)
|
||||
return results.flat()
|
||||
}
|
||||
|
||||
export async function countBgpSessions(): Promise<number> {
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
fetchBgpSessionsForAlerts(),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
|
||||
])
|
||||
if (!result) return 0
|
||||
return result.length
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { mapContainerRow } from "./containers-live.js"
|
||||
|
||||
const server = {
|
||||
id: 3,
|
||||
name: "mt-spb",
|
||||
host: "10.0.1.1",
|
||||
} as Parameters<typeof mapContainerRow>[0]
|
||||
|
||||
const row = mapContainerRow(
|
||||
server,
|
||||
{
|
||||
".id": "*A",
|
||||
name: "adguard",
|
||||
"remote-image": "adguard/adguardhome:latest",
|
||||
interface: "veth-adguard",
|
||||
envlist: "adguard-envs",
|
||||
mounts: "agh-conf,agh-work",
|
||||
status: "running",
|
||||
"start-on-boot": "true",
|
||||
comment: "DNS",
|
||||
},
|
||||
[
|
||||
{ name: "adguard-envs", key: "FOO", value: "bar" },
|
||||
{ name: "other", key: "SKIP", value: "x" },
|
||||
],
|
||||
[
|
||||
{ name: "agh-conf", dst: "/opt/conf", src: "/disk1/conf" },
|
||||
{ name: "agh-work", dst: "/opt/work" },
|
||||
],
|
||||
0,
|
||||
)
|
||||
|
||||
assert.equal(row.rosId, "*A")
|
||||
assert.equal(row.image, "adguard/adguardhome")
|
||||
assert.equal(row.tag, "latest")
|
||||
assert.equal(row.status, "running")
|
||||
assert.deepEqual(row.interfaces, ["veth-adguard"])
|
||||
assert.deepEqual(row.envs, [{ key: "FOO", value: "bar" }])
|
||||
assert.deepEqual(row.mounts, [
|
||||
{ dst: "/opt/conf", src: "/disk1/conf" },
|
||||
{ dst: "/opt/work", src: undefined },
|
||||
])
|
||||
assert.equal(row.startOnBoot, true)
|
||||
|
||||
console.log("containers-live.test.ts: ok")
|
||||
@@ -0,0 +1,215 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import { MikrotikClient, MikrotikError } from "./mikrotik.js"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
|
||||
interface RosContainer {
|
||||
".id"?: string
|
||||
name?: string
|
||||
tag?: string
|
||||
"remote-image"?: string
|
||||
interface?: string
|
||||
envlist?: string
|
||||
mounts?: string
|
||||
cmd?: string
|
||||
"start-on-boot"?: string
|
||||
comment?: string
|
||||
status?: string
|
||||
"memory-high"?: string
|
||||
cpu?: string
|
||||
}
|
||||
|
||||
interface RosContainerEnv {
|
||||
name?: string
|
||||
key?: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
interface RosContainerMount {
|
||||
name?: string
|
||||
src?: string
|
||||
dst?: string
|
||||
}
|
||||
|
||||
export type ContainerLiveStatus = "running" | "stopped" | "error"
|
||||
|
||||
export type ContainerLive = {
|
||||
id: string
|
||||
rosId: string
|
||||
name: string
|
||||
serverId: string
|
||||
image: string
|
||||
tag: string
|
||||
status: ContainerLiveStatus
|
||||
envs: { key: string; value: string }[]
|
||||
mounts: { dst: string; src?: string }[]
|
||||
interfaces: string[]
|
||||
cmd?: string
|
||||
startOnBoot: boolean
|
||||
comment: string
|
||||
uptime?: string
|
||||
cpu?: number
|
||||
memMb?: number
|
||||
}
|
||||
|
||||
function rosYes(v: string | undefined): boolean {
|
||||
return v === "true" || v === "yes"
|
||||
}
|
||||
|
||||
function mapStatus(raw: string | undefined): ContainerLiveStatus {
|
||||
const s = (raw ?? "").toLowerCase()
|
||||
if (s === "running") return "running"
|
||||
if (s === "error" || s === "failed") return "error"
|
||||
return "stopped"
|
||||
}
|
||||
|
||||
function splitCsv(v: string | undefined): string[] {
|
||||
return (v ?? "")
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function parseImageTag(c: RosContainer): { image: string; tag: string } {
|
||||
const remote = (c["remote-image"] ?? "").trim()
|
||||
if (remote) {
|
||||
const idx = remote.lastIndexOf(":")
|
||||
if (idx > 0 && !remote.slice(idx + 1).includes("/")) {
|
||||
return { image: remote.slice(0, idx), tag: remote.slice(idx + 1) }
|
||||
}
|
||||
return { image: remote, tag: (c.tag ?? "latest").trim() || "latest" }
|
||||
}
|
||||
return { image: (c.name ?? "").trim(), tag: (c.tag ?? "latest").trim() || "latest" }
|
||||
}
|
||||
|
||||
function isMissingPackage(err: unknown): boolean {
|
||||
if (err instanceof MikrotikError) {
|
||||
if (err.statusCode === 404) return true
|
||||
const body = err.body.toLowerCase()
|
||||
return body.includes("no such command") || body.includes("not found") || body.includes("unknown")
|
||||
}
|
||||
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase()
|
||||
return msg.includes("no such command") || msg.includes("404")
|
||||
}
|
||||
|
||||
export function mapContainerRow(
|
||||
server: ServerRow,
|
||||
c: RosContainer,
|
||||
envs: RosContainerEnv[],
|
||||
mounts: RosContainerMount[],
|
||||
idx: number,
|
||||
): ContainerLive {
|
||||
const rosId = String(c[".id"] ?? `c-${idx}`)
|
||||
const name = (c.name ?? "").trim() || `container-${idx + 1}`
|
||||
const { image, tag } = parseImageTag(c)
|
||||
const envlist = (c.envlist ?? "").trim()
|
||||
const mountNames = new Set(splitCsv(c.mounts))
|
||||
const envRows = envlist
|
||||
? envs.filter((e) => (e.name ?? "").trim() === envlist && (e.key ?? "").trim())
|
||||
: []
|
||||
const mountRows = mounts.filter((m) => mountNames.has((m.name ?? "").trim()) && (m.dst ?? "").trim())
|
||||
const cpuRaw = Number.parseInt(c.cpu ?? "", 10)
|
||||
const memRaw = Number.parseInt(c["memory-high"] ?? "", 10)
|
||||
return {
|
||||
id: `${server.id}-${rosId}`,
|
||||
rosId,
|
||||
name,
|
||||
serverId: String(server.id),
|
||||
image,
|
||||
tag,
|
||||
status: mapStatus(c.status),
|
||||
envs: envRows.map((e) => ({ key: e.key ?? "", value: e.value ?? "" })),
|
||||
mounts: mountRows.map((m) => ({ dst: m.dst ?? "", src: m.src || undefined })),
|
||||
interfaces: splitCsv(c.interface),
|
||||
cmd: (c.cmd ?? "").trim() || undefined,
|
||||
startOnBoot: rosYes(c["start-on-boot"]),
|
||||
comment: c.comment ?? "",
|
||||
cpu: Number.isFinite(cpuRaw) ? cpuRaw : undefined,
|
||||
memMb: Number.isFinite(memRaw) ? Math.round(memRaw / (1024 * 1024)) || undefined : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchContainersForServer(server: ServerRow): Promise<ContainerLive[]> {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const [raw, envsRaw, mountsRaw] = await Promise.all([
|
||||
client.get<RosContainer[]>("/container"),
|
||||
client.get<RosContainerEnv[]>("/container/envs").catch(() => [] as RosContainerEnv[]),
|
||||
client.get<RosContainerMount[]>("/container/mounts").catch(() => [] as RosContainerMount[]),
|
||||
])
|
||||
const list = Array.isArray(raw) ? raw : []
|
||||
const envs = Array.isArray(envsRaw) ? envsRaw : []
|
||||
const mounts = Array.isArray(mountsRaw) ? mountsRaw : []
|
||||
return list.map((c, idx) => mapContainerRow(server, c, envs, mounts, idx))
|
||||
} catch (err) {
|
||||
if (isMissingPackage(err)) return []
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function listContainers(): Promise<ContainerLive[]> {
|
||||
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
const results = await Promise.all(
|
||||
enabledServers.map(async (server) => {
|
||||
try {
|
||||
return await fetchContainersForServer(server)
|
||||
} catch {
|
||||
return [] as ContainerLive[]
|
||||
}
|
||||
}),
|
||||
)
|
||||
return results.flat()
|
||||
}
|
||||
|
||||
export async function listContainersForServer(server: ServerRow): Promise<ContainerLive[]> {
|
||||
try {
|
||||
return await fetchContainersForServer(server)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function countContainers(): Promise<number> {
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
listContainers(),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
|
||||
])
|
||||
if (!result) return 0
|
||||
return result.length
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function encodeRosId(rosId: string): string {
|
||||
return encodeURIComponent(rosId)
|
||||
}
|
||||
|
||||
export async function startContainer(server: ServerRow, rosId: string): Promise<void> {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
await client.post("/container/start", { ".id": rosId })
|
||||
}
|
||||
|
||||
export async function stopContainer(server: ServerRow, rosId: string): Promise<void> {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
await client.post("/container/stop", { ".id": rosId })
|
||||
}
|
||||
|
||||
export async function restartContainer(server: ServerRow, rosId: string): Promise<void> {
|
||||
await stopContainer(server, rosId)
|
||||
await startContainer(server, rosId)
|
||||
}
|
||||
|
||||
export async function removeContainer(server: ServerRow, rosId: string): Promise<void> {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
await client.delete(`/container/${encodeRosId(rosId)}`)
|
||||
}
|
||||
|
||||
export async function getEnabledServerById(serverId: string | number) {
|
||||
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
|
||||
if (!Number.isFinite(id)) return null
|
||||
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict"
|
||||
import type { RosIpRoute } from "../types/server.js"
|
||||
import { parseOspfGateway, parseOspfRouteType } from "./ospf-route-parse.js"
|
||||
|
||||
function route(partial: Partial<RosIpRoute>): RosIpRoute {
|
||||
return { ".id": "*1", "dst-address": "10.0.0.0/8", ...partial }
|
||||
}
|
||||
|
||||
assert.equal(parseOspfRouteType(route({ static: "true" })), null)
|
||||
assert.equal(parseOspfRouteType(route({ bgp: "true" })), null)
|
||||
assert.equal(parseOspfRouteType(route({ ospf: "true" })), "O")
|
||||
assert.equal(parseOspfRouteType(route({ "ospf-type": "intra-area" })), "O")
|
||||
assert.equal(parseOspfRouteType(route({ ospf: "true", "ospf-type": "inter-area" })), "O IA")
|
||||
assert.equal(parseOspfRouteType(route({ ospf: "true", "ospf-type": "ext-type-1" })), "O E1")
|
||||
assert.equal(parseOspfRouteType(route({ ospf: "true", "ospf-type": "type-2" })), "O E2")
|
||||
|
||||
assert.deepEqual(parseOspfGateway(route({ gateway: "10.200.0.1%gre-msk-spb" })), {
|
||||
nextHop: "10.200.0.1",
|
||||
via: "gre-msk-spb",
|
||||
})
|
||||
|
||||
console.log("ospf-route-parse.test.ts: ok")
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { RosIpRoute } from "../types/server.js"
|
||||
|
||||
export type OspfRouteKind = "O" | "O IA" | "O E1" | "O E2"
|
||||
|
||||
/** RouterOS /ip/route → тип OSPF-маршрута UI, либо null если маршрут не OSPF. */
|
||||
export function parseOspfRouteType(r: RosIpRoute): OspfRouteKind | null {
|
||||
const ospfFlag = r.ospf === "true" || r.ospf === "yes"
|
||||
const raw = `${r["ospf-type"] ?? ""} ${r.type ?? ""}`.toLowerCase()
|
||||
const looksOspf = ospfFlag || raw.includes("ospf") || Boolean(r["ospf-type"])
|
||||
if (!looksOspf) return null
|
||||
if (raw.includes("inter")) return "O IA"
|
||||
if (raw.includes("e1") || raw.includes("type-1") || raw.includes("ext-1") || raw.includes("nssa-ext-type-1")) {
|
||||
return "O E1"
|
||||
}
|
||||
if (raw.includes("e2") || raw.includes("type-2") || raw.includes("ext-2") || raw.includes("nssa-ext-type-2")) {
|
||||
return "O E2"
|
||||
}
|
||||
return "O"
|
||||
}
|
||||
|
||||
export function parseOspfGateway(r: RosIpRoute): { nextHop: string; via: string } {
|
||||
const gw = (r.gateway ?? r["immediate-gw"] ?? "").trim()
|
||||
const [ip, iface = ""] = gw.split("%")
|
||||
return {
|
||||
nextHop: ip || gw || "—",
|
||||
via: iface || (r.interface ?? "—"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { mapVxlanRow } from "./vxlan-live.js"
|
||||
|
||||
const server = {
|
||||
id: 7,
|
||||
name: "mt-msk",
|
||||
host: "10.0.0.1",
|
||||
site: "MSK",
|
||||
country: "RU",
|
||||
} as Parameters<typeof mapVxlanRow>[0]
|
||||
|
||||
const row = mapVxlanRow(
|
||||
server,
|
||||
{
|
||||
".id": "*3",
|
||||
name: "vxlan-10",
|
||||
vni: "10010",
|
||||
port: "8472",
|
||||
"local-address": "10.0.0.1",
|
||||
running: "true",
|
||||
disabled: "false",
|
||||
l2mtu: "1500",
|
||||
"mac-learning": "true",
|
||||
"arp-proxy": "true",
|
||||
comment: "overlay",
|
||||
},
|
||||
[
|
||||
{ interface: "vxlan-10", "remote-ip": "10.0.1.1" },
|
||||
{ interface: "other", "remote-ip": "1.1.1.1" },
|
||||
{ interface: "vxlan-10", "remote-ip": "10.0.2.1" },
|
||||
],
|
||||
0,
|
||||
)
|
||||
|
||||
assert.equal(row.serverId, "7")
|
||||
assert.equal(row.vni, 10010)
|
||||
assert.equal(row.dstPort, 8472)
|
||||
assert.equal(row.status, "up")
|
||||
assert.equal(row.enabled, true)
|
||||
assert.deepEqual(row.remoteVteps, ["10.0.1.1", "10.0.2.1"])
|
||||
assert.equal(row.vtepIp, "10.0.0.1")
|
||||
|
||||
console.log("vxlan-live.test.ts: ok")
|
||||
@@ -0,0 +1,136 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
|
||||
interface RosVxlan {
|
||||
".id"?: string
|
||||
name?: string
|
||||
vni?: string
|
||||
port?: string
|
||||
"local-address"?: string
|
||||
"vtep-address"?: string
|
||||
running?: string
|
||||
disabled?: string
|
||||
comment?: string
|
||||
l2mtu?: string
|
||||
arp?: string
|
||||
"arp-proxy"?: string
|
||||
"mac-learning"?: string
|
||||
learning?: string
|
||||
}
|
||||
|
||||
interface RosVxlanVtep {
|
||||
".id"?: string
|
||||
interface?: string
|
||||
"remote-ip"?: string
|
||||
}
|
||||
|
||||
export type VxlanTunnelLive = {
|
||||
id: string
|
||||
rosId: string
|
||||
name: string
|
||||
vni: number
|
||||
port: number
|
||||
dstPort: number
|
||||
serverId: string
|
||||
vtepIp: string
|
||||
remoteVteps: string[]
|
||||
l2mtu: number
|
||||
arpProxy: boolean
|
||||
macLearning: boolean
|
||||
comment: string
|
||||
enabled: boolean
|
||||
status: "up" | "down"
|
||||
}
|
||||
|
||||
function rosYes(v: string | undefined): boolean {
|
||||
return v === "true" || v === "yes"
|
||||
}
|
||||
|
||||
function parseIntSafe(v: string | undefined, fallback: number): number {
|
||||
const n = Number.parseInt(v ?? "", 10)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
export function mapVxlanRow(
|
||||
server: ServerRow,
|
||||
vx: RosVxlan,
|
||||
vteps: RosVxlanVtep[],
|
||||
idx: number,
|
||||
): VxlanTunnelLive {
|
||||
const name = (vx.name ?? "").trim() || `vxlan-${idx + 1}`
|
||||
const rosId = String(vx[".id"] ?? name)
|
||||
const disabled = rosYes(vx.disabled)
|
||||
const running = rosYes(vx.running)
|
||||
const port = parseIntSafe(vx.port, 8472)
|
||||
const remoteVteps = vteps
|
||||
.filter((v) => (v.interface ?? "").trim() === name)
|
||||
.map((v) => (v["remote-ip"] ?? "").trim())
|
||||
.filter(Boolean)
|
||||
return {
|
||||
id: `${server.id}-${rosId}`,
|
||||
rosId,
|
||||
name,
|
||||
vni: parseIntSafe(vx.vni, 0),
|
||||
port: 0,
|
||||
dstPort: port,
|
||||
serverId: String(server.id),
|
||||
vtepIp: (vx["local-address"] ?? vx["vtep-address"] ?? "").trim(),
|
||||
remoteVteps,
|
||||
l2mtu: parseIntSafe(vx.l2mtu, 1500),
|
||||
arpProxy: rosYes(vx["arp-proxy"]) || vx.arp === "proxy-arp" || vx.arp === "enabled",
|
||||
macLearning: vx["mac-learning"] != null ? rosYes(vx["mac-learning"]) : vx.learning !== "false",
|
||||
comment: vx.comment ?? "",
|
||||
enabled: !disabled,
|
||||
status: !disabled && running ? "up" : "down",
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVxlanForServer(server: ServerRow): Promise<VxlanTunnelLive[]> {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const [vxRaw, vtepRaw] = await Promise.all([
|
||||
client.get<RosVxlan[]>("/interface/vxlan"),
|
||||
client.get<RosVxlanVtep[]>("/interface/vxlan/vteps").catch(() => [] as RosVxlanVtep[]),
|
||||
])
|
||||
const list = Array.isArray(vxRaw) ? vxRaw : []
|
||||
const vteps = Array.isArray(vtepRaw) ? vtepRaw : []
|
||||
return list.map((vx, idx) => mapVxlanRow(server, vx, vteps, idx))
|
||||
}
|
||||
|
||||
export async function listVxlanTunnels(): Promise<VxlanTunnelLive[]> {
|
||||
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
const results = await Promise.all(
|
||||
enabledServers.map(async (server) => {
|
||||
try {
|
||||
return await fetchVxlanForServer(server)
|
||||
} catch {
|
||||
return [] as VxlanTunnelLive[]
|
||||
}
|
||||
}),
|
||||
)
|
||||
return results.flat()
|
||||
}
|
||||
|
||||
export async function listVxlanTunnelsForServer(server: ServerRow): Promise<VxlanTunnelLive[]> {
|
||||
try {
|
||||
return await fetchVxlanForServer(server)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function countVxlanTunnels(): Promise<number> {
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
listVxlanTunnels(),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
|
||||
])
|
||||
if (!result) return 0
|
||||
return result.length
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -285,6 +285,19 @@ export interface OspfInterfaceRead {
|
||||
useBfd: boolean
|
||||
}
|
||||
|
||||
export interface OspfRouteRead {
|
||||
id: string
|
||||
serverId: number
|
||||
serverName: string
|
||||
serverSite: string
|
||||
destination: string
|
||||
type: "O" | "O IA" | "O E1" | "O E2"
|
||||
cost: number
|
||||
nextHop: string
|
||||
via: string
|
||||
area: string
|
||||
}
|
||||
|
||||
export interface OspfInstanceRead {
|
||||
id: string
|
||||
serverId: number
|
||||
@@ -306,6 +319,7 @@ export interface RosIpRoute {
|
||||
"dst-address": string
|
||||
"pref-src"?: string
|
||||
"gateway"?: string
|
||||
"immediate-gw"?: string
|
||||
"distance"?: string
|
||||
"scope"?: string
|
||||
"active"?: string // "true"
|
||||
@@ -314,6 +328,9 @@ export interface RosIpRoute {
|
||||
"connect"?: string
|
||||
"bgp"?: string
|
||||
"ospf"?: string
|
||||
"ospf-type"?: string
|
||||
"ospf-metric"?: string
|
||||
"ospf-area"?: string
|
||||
"rip"?: string
|
||||
"blackhole"?: string
|
||||
"unreachable"?: string
|
||||
|
||||
Reference in New Issue
Block a user