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
2026-05-08 00:40:41 +07:00
parent 11ad94f67d
commit b9a75b6831
15 changed files with 1740 additions and 14 deletions
+26
View File
@@ -205,6 +205,26 @@ CREATE TABLE IF NOT EXISTS scheduler_runs (
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time
ON scheduler_runs(job_key, finished_at);
CREATE TABLE IF NOT EXISTS internet_path_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 300,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sampled_at TEXT NOT NULL,
payload_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled
ON internet_path_snapshots(sampled_at DESC);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
@@ -516,6 +536,12 @@ SELECT 1, 0, 120
WHERE NOT EXISTS (SELECT 1 FROM servers_api_ping_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO internet_path_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 300, 14
WHERE NOT EXISTS (SELECT 1 FROM internet_path_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO alert_telegram_settings (id, bot_token, chat_id)
SELECT 1, '', ''
+20
View File
@@ -464,6 +464,24 @@ export const uptimeSpeedTestRuns = sqliteTable("uptime_speed_test_runs", {
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
})
export const internetPathSettings = sqliteTable("internet_path_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(300),
retentionDays: integer("retention_days").notNull().default(14),
lastCollectedAt: text("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
id: integer("id").primaryKey({ autoIncrement: true }),
sampledAt: text("sampled_at").notNull(),
payloadJson: text("payload_json").notNull(),
})
// ── inferred types ─────────────────────────────────────────────────────────────
export type Server = typeof servers.$inferSelect
@@ -481,6 +499,8 @@ export type UptimeProbeSampleRow = typeof uptimeProbeSamples.$inferSelect
export type UptimeResourceSampleRow = typeof uptimeResourceSamples.$inferSelect
export type UptimeSpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect
export type UptimeSpeedTestRunRow = typeof uptimeSpeedTestRuns.$inferSelect
export type InternetPathSettingsRow = typeof internetPathSettings.$inferSelect
export type InternetPathSnapshotRow = typeof internetPathSnapshots.$inferSelect
export type SchedulerRunRow = typeof schedulerRuns.$inferSelect
export type EventRow = typeof events.$inferSelect
export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
+2
View File
@@ -12,6 +12,7 @@ import trafficRoutes from "./routes/traffic.js"
import serversApiPingRoutes from "./routes/servers-api-ping.js"
import uptimeRoutes from "./routes/uptime.js"
import networkRoutes from "./routes/network.js"
import internetPathRoutes from "./routes/internet-path.js"
import evobgpRoutes from "./routes/evobgp.js"
import probesRoutes from "./routes/probes.js"
import schedulerRoutes from "./routes/scheduler.js"
@@ -57,6 +58,7 @@ await app.register(trafficRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
await app.register(internetPathRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
+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))
@@ -0,0 +1,312 @@
import { and, asc, eq, lt } from "drizzle-orm"
import { db } from "../db/index.js"
import {
filterRules,
internetPathSettings,
internetPathSnapshots,
servers,
uptimeSpeedProbes,
} from "../db/schema.js"
import { listServersRead } from "../modules/servers/service/servers-service.js"
import { MikrotikClient } from "./mikrotik.js"
import type { InternetPathRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
const INTERNET_TARGET = "1.1.1.1"
let collecting = false
function isTrue(v: unknown): boolean {
const s = String(v ?? "").trim().toLowerCase()
return s === "true" || s === "yes"
}
function toIp(raw: string | null | undefined): string | null {
const v = String(raw ?? "").trim()
if (!v) return null
return v.split("/")[0]?.trim() ?? null
}
function norm(v: string | null | undefined): string {
return String(v ?? "").trim().toLowerCase()
}
function getSettingsRow() {
const row = db.select().from(internetPathSettings).where(eq(internetPathSettings.id, 1)).limit(1).all()[0]
if (row) return row
const now = new Date().toISOString()
db.insert(internetPathSettings).values({
id: 1,
enabled: true,
intervalSec: 300,
retentionDays: 14,
createdAt: now,
updatedAt: now,
}).run()
return db.select().from(internetPathSettings).where(eq(internetPathSettings.id, 1)).limit(1).all()[0]
}
function cleanupSnapshots(retentionDays: number) {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
db.delete(internetPathSnapshots).where(lt(internetPathSnapshots.sampledAt, cutoff)).run()
}
function buildRulesets() {
const enabled = db.select().from(servers).where(eq(servers.enabled, true)).all()
const rules = db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder)).all()
return enabled.map((s) => ({
serverId: String(s.id),
rules: rules
.filter((r) => r.serverId === s.id)
.map((r) => ({
id: String(r.id),
community: r.community,
communityName: r.communityName ?? undefined,
action: r.action,
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
})),
}))
}
async function readRouteLookup(serverId: number): Promise<{ gateway: string | null; routingMark: string | null }> {
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!row) return { gateway: null, routingMark: null }
const client = MikrotikClient.fromServer(row)
const routes = await client.get<Array<Record<string, string>>>("/ip/route").catch(() => [])
const best = routes
.filter((r) => String(r["dst-address"] ?? "").trim() === "0.0.0.0/0")
.filter((r) => isTrue(r.active))
.filter((r) => {
const rt = String(r["routing-table"] ?? "").trim().toLowerCase()
return rt === "" || rt === "main"
})
.filter((r) => String(r.disabled ?? "false").toLowerCase() !== "true")
.filter((r) => String(r.inactive ?? "false").toLowerCase() !== "true")
.sort((a, b) => Number(a.distance ?? 255) - Number(b.distance ?? 255))[0]
return {
gateway: String(best?.gateway ?? "").trim() || null,
routingMark: String(best?.["routing-mark"] ?? "").trim() || null,
}
}
async function readWanRuntime(serverId: number) {
const server = listServersRead().find((s) => Number(s.id) === serverId)
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server || !row) return null
const client = MikrotikClient.fromServer(row)
const [dhcpRaw, ipAddrs, routes] = await Promise.all([
client.get<Array<Record<string, string>>>("/ip/dhcp-client").catch(() => []),
client.get<Array<Record<string, string>>>("/ip/address").catch(() => []),
client.get<Array<Record<string, string>>>("/ip/route").catch(() => []),
])
const defaultRoute = routes
.filter((r) => String(r["dst-address"] ?? "").trim() === "0.0.0.0/0")
.filter((r) => isTrue(r.active))
.filter((r) => {
const rt = String(r["routing-table"] ?? "").trim().toLowerCase()
return rt === "" || rt === "main"
})
.filter((r) => String(r.disabled ?? "false").toLowerCase() !== "true")
.filter((r) => String(r.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?.["immediate-gw"] ?? "").trim() || null
const defaultInterface =
((immediateGw?.includes("%") ? immediateGw.split("%")[1]?.trim() : ""))
|| String(defaultRoute?.interface ?? "").trim()
|| String(
dhcpRaw.find((d) => toIp(d.gateway) != null && toIp(d.gateway) === toIp(defaultGateway))?.interface ?? "",
).trim()
|| null
const uplinks = (server.wanUplinks ?? []).map((w) => {
const iface = String(w.iface ?? "").trim()
const dhcp = dhcpRaw.find((d) => norm(d.interface) === norm(iface))
const leasedIp =
toIp(dhcp?.address)
?? toIp(ipAddrs.find((a) => norm(a.interface) === norm(iface))?.address)
?? null
return {
id: w.id,
iface,
name: w.name,
isp: w.isp,
configuredIp: w.ip,
leasedIp,
dhcpStatus: String(dhcp?.status ?? "").trim() || null,
isDefault: defaultInterface != null && norm(defaultInterface) === norm(iface),
}
})
return {
defaultGateway,
defaultInterface,
uplinks,
}
}
function mapSpeedProbes() {
const rows = db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder)).all()
return rows.map((r) => ({
id: r.id,
srcServerId: String(r.srcServerId),
dstServerId: String(r.dstServerId),
srcInterface: r.srcInterface || "",
dstInterface: r.dstInterface || "",
protocol: r.protocol === "udp" ? "udp" : "tcp",
direction: r.direction === "transmit" || r.direction === "receive" ? r.direction : "both",
durationSec: String(Math.max(3, r.durationSec || 10)),
enabled: r.enabled !== false,
lastRunAt: r.lastRunAt ?? null,
lastTxAvgMbps: r.lastTxAvgMbps ?? null,
lastRxAvgMbps: r.lastRxAvgMbps ?? null,
lastStatus: r.lastStatus ?? null,
lastError: r.lastError ?? null,
lastPingRttMs: r.lastPingRttMs ?? null,
lastPingLossPct: r.lastPingLossPct ?? null,
lastPingAt: r.lastPingAt ?? null,
lastPingError: r.lastPingError ?? null,
}))
}
function parseInnerIps(comment: string): { localInnerIp: string; remoteInnerIp: string } {
const local = comment.match(/address\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
const remote = comment.match(/(?:network|gateway)\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
return { localInnerIp: local, remoteInnerIp: remote }
}
async function collectGreTunnels() {
const enabled = db.select().from(servers).where(eq(servers.enabled, true)).all()
const all = await Promise.all(enabled.map(async (srv) => {
try {
const client = MikrotikClient.fromServer(srv)
const rows = await client.get<Array<Record<string, string>>>("/interface/gre")
return rows.map((g, idx) => {
const keepalive = String(g.keepalive ?? "0,0").split(",")
const inner = parseInnerIps(String(g.comment ?? ""))
return {
id: String(g.name ?? g[".id"] ?? `gre-${srv.id}-${idx}`),
name: String(g.name ?? `gre-${idx + 1}`),
serverId: String(srv.id),
localAddress: String(g["local-address"] ?? ""),
remoteAddress: String(g["remote-address"] ?? ""),
localInnerIp: inner.localInnerIp,
remoteInnerIp: inner.remoteInnerIp,
poolId: "live",
ipsec: null,
mtu: Number.parseInt(String(g.mtu ?? "1476"), 10) || 1476,
keepaliveInterval: Number.parseInt(String(keepalive[0] ?? "0"), 10) || 0,
keepaliveRetries: Number.parseInt(String(keepalive[1] ?? "0"), 10) || 0,
dscp: "inherit" as const,
clampTcpMss: String(g["clamp-tcp-mss"] ?? "true") !== "false",
allowFastPath: String(g["allow-fast-path"] ?? "true") !== "false",
comment: String(g.comment ?? ""),
enabled: String(g.disabled ?? "false") !== "true",
status:
String(g.disabled ?? "false") === "true"
? "down" as const
: (String(g.running ?? "false") === "true" ? "up" as const : "degraded" as const),
}
})
} catch {
return []
}
}))
return all.flat()
}
export function getInternetPathSettings() {
return getSettingsRow()
}
export function updateInternetPathSettings(patch: { enabled?: boolean; intervalSec?: number; retentionDays?: number }) {
const prev = getSettingsRow()
db.update(internetPathSettings).set({
enabled: patch.enabled ?? prev.enabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
retentionDays: patch.retentionDays ?? prev.retentionDays,
updatedAt: new Date().toISOString(),
}).where(eq(internetPathSettings.id, 1)).run()
return getSettingsRow()
}
export function getLatestInternetPathSnapshot() {
return db.select().from(internetPathSnapshots).orderBy(asc(internetPathSnapshots.id)).all().at(-1) ?? null
}
export async function collectInternetPathSnapshotOnce(): Promise<InternetPathRunSnapshot> {
const sampledAt = new Date().toISOString()
if (collecting) {
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "internet_path",
sampledAt,
homes: 0,
snapshotSaved: false,
}
}
collecting = true
const started = Date.now()
const settings = getSettingsRow()
try {
const serversRead = listServersRead()
const homes = serversRead.filter((s) => s.type === "home-router")
const [greTunnels, rulesets] = await Promise.all([collectGreTunnels(), Promise.resolve(buildRulesets())])
const speedProbes = mapSpeedProbes()
const routeLookupByServerId: Record<string, { gateway: string | null; routingMark: string | null }> = {}
const wanRuntimeByHomeId: Record<string, unknown> = {}
for (const h of homes) {
routeLookupByServerId[String(h.id)] = await readRouteLookup(Number(h.id)).catch(() => ({ gateway: null, routingMark: null }))
wanRuntimeByHomeId[String(h.id)] = await readWanRuntime(Number(h.id)).catch(() => null)
}
const payload = {
sampledAt,
internetTarget: INTERNET_TARGET,
servers: serversRead,
greTunnels,
filtersRulesets: rulesets,
speedProbes,
routeLookupByServerId,
wanRuntimeByHomeId,
}
db.insert(internetPathSnapshots).values({
sampledAt,
payloadJson: JSON.stringify(payload),
}).run()
cleanupSnapshots(Math.max(1, settings.retentionDays))
db.update(internetPathSettings).set({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: "",
updatedAt: sampledAt,
}).where(eq(internetPathSettings.id, 1)).run()
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "internet_path",
sampledAt,
homes: homes.length,
snapshotSaved: true,
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
db.update(internetPathSettings).set({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: msg,
updatedAt: sampledAt,
}).where(eq(internetPathSettings.id, 1)).run()
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "internet_path",
sampledAt,
homes: 0,
snapshotSaved: false,
fatalError: msg,
}
} finally {
collecting = false
}
}
export function isInternetPathCollecting(): boolean {
return collecting
}
+23
View File
@@ -38,6 +38,10 @@ import {
scheduleAlertEngineAfterDataCollectors,
wireAlertEngineRunner,
} from "./alert-collector-hooks.js"
import {
collectInternetPathSnapshotOnce,
getInternetPathSettings,
} from "./internet-path-collector.js"
import {
endSchedulerJob,
isSchedulerJobRunning,
@@ -51,6 +55,7 @@ export const JOB_KEYS = [
"uptime_resources",
"uptime_ping",
"uptime_speed",
"internet_path",
"gre_bgp",
"alert_engine",
] as const
@@ -111,6 +116,9 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
case "gre_bgp":
snapshot = await collectGreBgpSnapshotOnce()
break
case "internet_path":
snapshot = await collectInternetPathSnapshotOnce()
break
case "alert_engine": {
const r = await runAlertEngineOnce()
snapshot = {
@@ -158,6 +166,7 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
jobKey === "uptime_resources" ||
jobKey === "uptime_ping" ||
jobKey === "uptime_speed" ||
jobKey === "internet_path" ||
jobKey === "gre_bgp"
) {
scheduleAlertEngineAfterDataCollectors()
@@ -243,6 +252,7 @@ export function refreshScheduler(): void {
}
const apiPing = getServersApiPingSettings()
const internetPath = getInternetPathSettings()
if (apiPing.enabled) {
const apiMs = Math.max(10_000, apiPing.intervalSec * 1000)
void executeSchedulerJob("servers_rest_ping").catch(() => {})
@@ -292,6 +302,17 @@ export function refreshScheduler(): void {
)
}
if (internetPath.enabled) {
const internetPathMs = Math.max(30_000, internetPath.intervalSec * 1000)
void executeSchedulerJob("internet_path").catch(() => {})
timers.set(
"internet_path",
setInterval(() => {
void executeSchedulerJob("internet_path").catch(() => {})
}, internetPathMs),
)
}
const greBgpMs = 30_000
void executeSchedulerJob("gre_bgp").catch(() => {})
timers.set(
@@ -331,6 +352,7 @@ export function getSchedulerStatus() {
const traffic = getTrafficSettings()
const uptime = getUptimeSettings()
const apiPing = getServersApiPingSettings()
const internetPath = getInternetPathSettings()
const resOn = uptime.resourcesEnabled ?? uptime.enabled
const pingOn = uptime.pingEnabled ?? uptime.enabled
@@ -342,6 +364,7 @@ export function getSchedulerStatus() {
uptime_resources: { enabled: resOn, intervalSec: uptime.intervalSec },
uptime_ping: { enabled: pingOn, intervalSec: uptime.probeIntervalSec },
uptime_speed: { enabled: spdOn, intervalSec: uptime.speedIntervalSec },
internet_path: { enabled: internetPath.enabled, intervalSec: internetPath.intervalSec },
gre_bgp: { enabled: true, intervalSec: 30 },
alert_engine: { enabled: true, intervalSec: 20 },
}
@@ -183,6 +183,15 @@ export interface GreBgpSnapshotRunSnapshot {
errors?: string[]
}
export interface InternetPathRunSnapshot {
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
job: "internet_path"
sampledAt: string
homes: number
snapshotSaved: boolean
fatalError?: string
}
export type SchedulerRunSnapshot =
| TrafficRunSnapshot
| ResourcesRunSnapshot
@@ -190,4 +199,5 @@ export type SchedulerRunSnapshot =
| SpeedScheduledRunSnapshot
| ServersRestPingRunSnapshot
| GreBgpSnapshotRunSnapshot
| InternetPathRunSnapshot
| AlertEngineRunSnapshot