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:
Denozordec committed 2026-05-08 00:40:41 +07:00
1 parent 11ad94f67d
commit b9a75b6831
15 files changed
+1740 -14

No files matched your search

+63
View File
@@ -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
+18 -2
View File
@@ -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 = [
+136
View File
@@ -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))