feat: implement internet path functionality with backend support
Added internet path settings and snapshot management to the application. This includes new database tables for internet path settings and snapshots, API routes for fetching and managing internet path data, and integration into the dashboard and data collection pages. Enhanced the scheduler to support internet path jobs, ensuring regular data collection and updates. Updated relevant types and interfaces to accommodate the new functionality.
This commit is contained in:
1 parent
11ad94f67d
commit
b9a75b6831
15 files changed
+1740
-14
No files matched your search
@@ -0,0 +1,63 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { refreshScheduler } from "../services/scheduler.js"
|
||||
import {
|
||||
getInternetPathSettings,
|
||||
getLatestInternetPathSnapshot,
|
||||
updateInternetPathSettings,
|
||||
} from "../services/internet-path-collector.js"
|
||||
|
||||
const internetPathRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/internet-path/settings", async (_req, reply) => {
|
||||
const s = getInternetPathSettings()
|
||||
return reply.send({
|
||||
enabled: s.enabled,
|
||||
intervalSec: s.intervalSec,
|
||||
retentionDays: s.retentionDays,
|
||||
lastCollectedAt: s.lastCollectedAt ?? null,
|
||||
lastDurationMs: s.lastDurationMs ?? null,
|
||||
lastError: s.lastError || null,
|
||||
})
|
||||
})
|
||||
|
||||
app.put("/internet-path/settings", async (req, reply) => {
|
||||
const body = req.body as {
|
||||
enabled?: boolean
|
||||
intervalSec?: number | string
|
||||
retentionDays?: number | string
|
||||
}
|
||||
const updated = updateInternetPathSettings({
|
||||
enabled: body.enabled,
|
||||
intervalSec: body.intervalSec == null ? undefined : Math.max(30, Number.parseInt(String(body.intervalSec), 10) || 300),
|
||||
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
|
||||
})
|
||||
refreshScheduler()
|
||||
return reply.send({
|
||||
ok: true,
|
||||
settings: {
|
||||
enabled: updated.enabled,
|
||||
intervalSec: updated.intervalSec,
|
||||
retentionDays: updated.retentionDays,
|
||||
lastCollectedAt: updated.lastCollectedAt ?? null,
|
||||
lastDurationMs: updated.lastDurationMs ?? null,
|
||||
lastError: updated.lastError || null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
app.get("/internet-path/latest", async (_req, reply) => {
|
||||
const row = getLatestInternetPathSnapshot()
|
||||
if (!row) return reply.send({ snapshot: null })
|
||||
let payload: unknown = null
|
||||
try {
|
||||
payload = JSON.parse(row.payloadJson)
|
||||
} catch {
|
||||
payload = null
|
||||
}
|
||||
return reply.send({
|
||||
snapshot: payload,
|
||||
sampledAt: row.sampledAt,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default internetPathRoutes
|
||||
@@ -141,10 +141,19 @@ function fmtBandwidth(rows: Array<Record<string, string>>): string {
|
||||
}
|
||||
|
||||
function fmtRouteLookup(destIp: string, routes: RosIpRoute[]): string {
|
||||
const isTrue = (v: unknown) => {
|
||||
const s = String(v ?? "").trim().toLowerCase()
|
||||
return s === "true" || s === "yes"
|
||||
}
|
||||
const matches = routes.filter((r) => {
|
||||
const dst = String(r["dst-address"] ?? "").trim()
|
||||
if (!dst) return false
|
||||
if (String(r.active ?? "true").toLowerCase() === "false") return false
|
||||
// Критично: учитывать только реально ACTIVE маршруты из /ip/route.
|
||||
if (!isTrue(r.active)) return false
|
||||
const rt = String((r as unknown as Record<string, unknown>)["routing-table"] ?? "").trim().toLowerCase()
|
||||
if (!(rt === "" || rt === "main")) return false
|
||||
if (String((r as unknown as Record<string, unknown>).disabled ?? "false").toLowerCase() === "true") return false
|
||||
if (String((r as unknown as Record<string, unknown>).inactive ?? "false").toLowerCase() === "true") return false
|
||||
return ipMatchesRoute(destIp, dst)
|
||||
})
|
||||
if (matches.length === 0) {
|
||||
@@ -153,7 +162,14 @@ function fmtRouteLookup(destIp: string, routes: RosIpRoute[]): string {
|
||||
matches.sort((a, b) => {
|
||||
const da = parseDstRoute(String(a["dst-address"] ?? ""))
|
||||
const db = parseDstRoute(String(b["dst-address"] ?? ""))
|
||||
return (db?.maskBits ?? 0) - (da?.maskBits ?? 0)
|
||||
const maskCmp = (db?.maskBits ?? 0) - (da?.maskBits ?? 0)
|
||||
if (maskCmp !== 0) return maskCmp
|
||||
const distA = Number(a.distance ?? 255)
|
||||
const distB = Number(b.distance ?? 255)
|
||||
if (distA !== distB) return distA - distB
|
||||
const aHasGw = String(a.gateway ?? "").trim().length > 0 ? 0 : 1
|
||||
const bHasGw = String(b.gateway ?? "").trim().length > 0 ? 0 : 1
|
||||
return aHasGw - bHasGw
|
||||
})
|
||||
const best = matches[0]!
|
||||
const lines = [
|
||||
|
||||
@@ -90,6 +90,142 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.send({ host: server.host, ipv4 })
|
||||
})
|
||||
|
||||
// GET /api/servers/:id/wan-runtime — DHCP lease + active default route for HomeRouter WAN uplinks
|
||||
app.get("/:id/wan-runtime", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||
const params = req.params as ServerIdParams
|
||||
const server = getServerReadById(params.id)
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const toIp = (raw: string | null | undefined): string | null => {
|
||||
const v = String(raw ?? "").trim()
|
||||
if (!v) return null
|
||||
return v.split("/")[0]?.trim() ?? null
|
||||
}
|
||||
const ipv4ToUint = (ip: string): number | null => {
|
||||
const p = ip.split(".").map((x) => Number.parseInt(x, 10))
|
||||
if (p.length !== 4 || p.some((x) => !Number.isFinite(x) || x < 0 || x > 255)) return null
|
||||
return (((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0)
|
||||
}
|
||||
const maskFromLen = (len: number): number => {
|
||||
if (len <= 0) return 0
|
||||
if (len >= 32) return 0xffffffff
|
||||
return (~((1 << (32 - len)) - 1)) >>> 0
|
||||
}
|
||||
const parseDstRoute = (dst: string): { net: number; maskBits: number } | null => {
|
||||
const t = dst.trim()
|
||||
if (!t) return null
|
||||
if (!t.includes("/")) {
|
||||
const ip = ipv4ToUint(t)
|
||||
return ip == null ? null : { net: ip, maskBits: 32 }
|
||||
}
|
||||
const [addr, mb] = t.split("/")
|
||||
const ip = ipv4ToUint(addr.trim())
|
||||
const maskBits = Number.parseInt((mb ?? "").trim(), 10)
|
||||
if (ip == null || !Number.isFinite(maskBits) || maskBits < 0 || maskBits > 32) return null
|
||||
const mask = maskFromLen(maskBits)
|
||||
return { net: ip & mask, maskBits }
|
||||
}
|
||||
const routeHasIp = (routeDst: string, ip: string): boolean => {
|
||||
const ipu = ipv4ToUint(ip)
|
||||
const cidr = parseDstRoute(routeDst)
|
||||
if (ipu == null || !cidr) return false
|
||||
const mask = maskFromLen(cidr.maskBits)
|
||||
return (ipu & mask) === (cidr.net & mask)
|
||||
}
|
||||
const gatewayIface = (gw: string | null | undefined): string | null => {
|
||||
const v = String(gw ?? "").trim()
|
||||
if (!v) return null
|
||||
const idx = v.indexOf("%")
|
||||
if (idx < 0) return null
|
||||
return v.slice(idx + 1).trim() || null
|
||||
}
|
||||
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(getServerRowById(params.id)!)
|
||||
const isTrue = (v: unknown) => {
|
||||
const s = String(v ?? "").trim().toLowerCase()
|
||||
return s === "true" || s === "yes"
|
||||
}
|
||||
const [dhcpRaw, ipAddrs, routes] = await Promise.all([
|
||||
client.get<Array<Record<string, string>>>("/ip/dhcp-client").catch(() => []),
|
||||
client.getIpAddresses().catch(() => []),
|
||||
client.getIpRoutes().catch(() => []),
|
||||
])
|
||||
|
||||
const defaultRoute = routes
|
||||
.filter((r) => String(r["dst-address"] ?? "").trim() === "0.0.0.0/0")
|
||||
// Только реально ACTIVE default routes.
|
||||
.filter((r) => isTrue((r as unknown as Record<string, unknown>).active))
|
||||
// Эквивалент CLI: routing-table=main
|
||||
.filter((r) => {
|
||||
const rt = String((r as unknown as Record<string, unknown>)["routing-table"] ?? "").trim().toLowerCase()
|
||||
return rt === "" || rt === "main"
|
||||
})
|
||||
.filter((r) => String((r as unknown as Record<string, unknown>).disabled ?? "false").toLowerCase() !== "true")
|
||||
.filter((r) => String((r as unknown as Record<string, unknown>).inactive ?? "false").toLowerCase() !== "true")
|
||||
.sort((a, b) => Number(a.distance ?? 255) - Number(b.distance ?? 255))[0]
|
||||
|
||||
const defaultGateway = String(defaultRoute?.gateway ?? "").trim() || null
|
||||
const immediateGw = String((defaultRoute as unknown as Record<string, unknown> | undefined)?.["immediate-gw"] ?? "").trim() || null
|
||||
const gwFromImmediate = immediateGw ? immediateGw.split("%")[0]?.trim() ?? null : null
|
||||
const gwIp = toIp(defaultGateway) ?? gwFromImmediate
|
||||
const dhcpIfaceByGateway = gwIp == null
|
||||
? null
|
||||
: (
|
||||
dhcpRaw.find((d) => {
|
||||
const status = String(d.status ?? "").trim().toLowerCase()
|
||||
if (status && status !== "bound") return false
|
||||
return toIp(d.gateway) === gwIp
|
||||
})
|
||||
)
|
||||
const directIfaceByGwSubnet =
|
||||
gwIp == null
|
||||
? null
|
||||
: (
|
||||
routes
|
||||
.filter((r) => String((r as unknown as Record<string, unknown>).active ?? "").toLowerCase() === "true")
|
||||
.filter((r) => String((r as unknown as Record<string, unknown>)["dst-address"] ?? "").trim() !== "0.0.0.0/0")
|
||||
.filter((r) => String((r as unknown as Record<string, unknown>).disabled ?? "false").toLowerCase() !== "true")
|
||||
.filter((r) => String((r as unknown as Record<string, unknown>).inactive ?? "false").toLowerCase() !== "true")
|
||||
.find((r) => routeHasIp(String((r as unknown as Record<string, unknown>)["dst-address"] ?? ""), gwIp))
|
||||
)
|
||||
const defaultInterface =
|
||||
gatewayIface(immediateGw)
|
||||
|| gatewayIface(defaultGateway)
|
||||
|| String((dhcpIfaceByGateway as unknown as Record<string, unknown> | undefined)?.interface ?? "").trim()
|
||||
|| String(defaultRoute?.interface ?? "").trim()
|
||||
|| String((directIfaceByGwSubnet as unknown as Record<string, unknown> | undefined)?.interface ?? "").trim()
|
||||
|| null
|
||||
|
||||
const uplinks = (server.wanUplinks ?? []).map((w) => {
|
||||
const iface = String(w.iface ?? "").trim()
|
||||
const dhcp = dhcpRaw.find((d) => String(d.interface ?? "").trim() === iface)
|
||||
const fromDhcp = toIp(dhcp?.address)
|
||||
const fromIpAddr = toIp(ipAddrs.find((a) => String(a.interface ?? "").trim() === iface)?.address)
|
||||
const leasedIp = fromDhcp ?? fromIpAddr
|
||||
return {
|
||||
id: w.id,
|
||||
iface,
|
||||
name: w.name,
|
||||
isp: w.isp,
|
||||
configuredIp: w.ip,
|
||||
leasedIp,
|
||||
dhcpStatus: String(dhcp?.status ?? "").trim() || null,
|
||||
isDefault: defaultInterface != null && defaultInterface === iface,
|
||||
}
|
||||
})
|
||||
|
||||
return reply.send({
|
||||
defaultGateway,
|
||||
immediateGateway: immediateGw,
|
||||
defaultInterface,
|
||||
uplinks,
|
||||
})
|
||||
} catch (err) {
|
||||
return reply.status(502).send({ error: err instanceof Error ? err.message : "Failed to read WAN runtime" })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/servers
|
||||
app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => {
|
||||
return reply.status(201).send(createServer(req.body as ServerCreateRequest))
|
||||
|
||||
Reference in new issue
Block a user