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.
466 lines
15 KiB
TypeScript
466 lines
15 KiB
TypeScript
import type { GreTunnel, Server, WanUplink } from "@/lib/data"
|
|
import {
|
|
assignSpeedProbesToGreTunnels,
|
|
assignSpeedProbesToWanJhEdges,
|
|
mergeGreMetricsWithSpeedProbe,
|
|
wanJhEdgeMapKey,
|
|
} from "@/lib/map-gre-speed-probe"
|
|
import {
|
|
buildWanJhEdges,
|
|
findServerByGreRemote,
|
|
greTunnelProbe,
|
|
} from "@/lib/network-map-layout"
|
|
import {
|
|
buildLiveOptimizerData,
|
|
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
|
|
type FiltersRulesetRow,
|
|
type OptimizerApiServer,
|
|
type RouteOptimizerSpeedProbe,
|
|
} from "@/lib/route-optimizer-data"
|
|
|
|
export interface InternetPathRoute {
|
|
wanId: string
|
|
jhId?: string
|
|
exitId?: string
|
|
reason: string
|
|
}
|
|
|
|
export interface InternetPathHop {
|
|
home: Server
|
|
wan: WanUplink
|
|
jumpHost: Server
|
|
exitNode: Server
|
|
wanJhMetrics: {
|
|
pingMs: number | null
|
|
dlMbps: number | null
|
|
ulMbps: number | null
|
|
fromMonitoring: boolean
|
|
}
|
|
jhExitMetrics: {
|
|
pingMs: number | null
|
|
dlMbps: number | null
|
|
ulMbps: number | null
|
|
fromMonitoring: boolean
|
|
}
|
|
}
|
|
|
|
export interface InternetPathViewModel {
|
|
homeRouter: Server
|
|
activeWanUplink: WanUplink | null
|
|
fallbackJumpHost: Server | null
|
|
fallbackExitNode: Server | null
|
|
primaryHop: InternetPathHop | null
|
|
currentHop: InternetPathHop | null
|
|
primaryPath: InternetPathRoute | null
|
|
currentPath: InternetPathRoute | null
|
|
pathState: "healthy" | "degraded" | "failover" | "unknown"
|
|
directWan: {
|
|
enabled: boolean
|
|
gateway: string | null
|
|
leasedIp: string | null
|
|
iface: string | null
|
|
provider: string | null
|
|
}
|
|
}
|
|
|
|
export interface RouteLookupResult {
|
|
gateway: string | null
|
|
routingMark: string | null
|
|
}
|
|
|
|
export interface HomeWanRuntime {
|
|
defaultGateway: string | null
|
|
defaultInterface: string | null
|
|
uplinks: Array<{
|
|
id: string
|
|
iface: string
|
|
name: string
|
|
isp: string
|
|
configuredIp: string
|
|
leasedIp: string | null
|
|
dhcpStatus: string | null
|
|
isDefault: boolean
|
|
}>
|
|
}
|
|
|
|
const INTERNET_TARGET = "1.1.1.1"
|
|
|
|
function norm(v: string | null | undefined): string {
|
|
return String(v ?? "").trim().toLowerCase()
|
|
}
|
|
|
|
function parseRouteLookupOutput(output: string): RouteLookupResult {
|
|
const lines = output.split(/\r?\n/)
|
|
let gateway: string | null = null
|
|
let routingMark: string | null = null
|
|
for (const line of lines) {
|
|
const g = line.match(/^\s*gateway:\s*(.+?)\s*$/i)
|
|
if (g) gateway = g[1].trim()
|
|
const rm = line.match(/^\s*routing-mark:\s*(.+?)\s*$/i)
|
|
if (rm) routingMark = rm[1].trim()
|
|
}
|
|
return { gateway, routingMark }
|
|
}
|
|
|
|
function chooseActiveWan(
|
|
home: Server,
|
|
lookup: RouteLookupResult | null,
|
|
runtime: HomeWanRuntime | null,
|
|
): WanUplink | null {
|
|
const wans = home.wanUplinks ?? []
|
|
if (!wans.length) return null
|
|
|
|
// 1) Истина из MikroTik /ip/route + /ip/dhcp-client (wan-runtime)
|
|
if (runtime) {
|
|
const byDefaultFlag = runtime.uplinks.find((u) => u.isDefault)
|
|
if (byDefaultFlag) {
|
|
const byId = wans.find((w) => norm(w.id) === norm(byDefaultFlag.id))
|
|
if (byId) return byId
|
|
const byIface = wans.find((w) => norm(w.iface) === norm(byDefaultFlag.iface))
|
|
if (byIface) return byIface
|
|
}
|
|
if (runtime.defaultInterface) {
|
|
const byIface = wans.find((w) => norm(w.iface) === norm(runtime.defaultInterface))
|
|
if (byIface) return byIface
|
|
}
|
|
const runtimeByIface = runtime.uplinks.find((u) => norm(u.iface) === norm(runtime.defaultInterface))
|
|
if (runtimeByIface) {
|
|
const byId = wans.find((w) => norm(w.id) === norm(runtimeByIface.id))
|
|
if (byId) return byId
|
|
}
|
|
if (runtime.defaultGateway) {
|
|
const byGatewayIp = wans.find((w) => norm(w.ip) === norm(runtime.defaultGateway))
|
|
if (byGatewayIp) return byGatewayIp
|
|
}
|
|
const runtimeBound = runtime.uplinks.find((u) => (u.dhcpStatus ?? "").toLowerCase() === "bound")
|
|
if (runtimeBound) {
|
|
const byId = wans.find((w) => norm(w.id) === norm(runtimeBound.id))
|
|
if (byId) return byId
|
|
const byIface = wans.find((w) => norm(w.iface) === norm(runtimeBound.iface))
|
|
if (byIface) return byIface
|
|
}
|
|
}
|
|
|
|
// 2) Fallback: route-lookup
|
|
const gw = norm(lookup?.gateway)
|
|
if (gw) {
|
|
const byIface = wans.find((w) => norm(w.iface) === gw || gw.includes(norm(w.iface)))
|
|
if (byIface) return byIface
|
|
const byIp = wans.find((w) => norm(w.ip) === gw || gw.startsWith(`${norm(w.ip)}%`))
|
|
if (byIp) return byIp
|
|
}
|
|
return wans[0] ?? null
|
|
}
|
|
|
|
function syntheticWanForHome(home: Server): WanUplink {
|
|
return {
|
|
id: `wan-auto-${home.id}`,
|
|
name: "WAN-AUTO",
|
|
isp: "auto",
|
|
iface: "auto",
|
|
ip: home.host || "0.0.0.0",
|
|
maxDl: 100,
|
|
maxUl: 100,
|
|
}
|
|
}
|
|
|
|
function bestJhForWan(
|
|
home: Server,
|
|
wan: WanUplink,
|
|
jhs: Server[],
|
|
probes: RouteOptimizerSpeedProbe[],
|
|
): Server | null {
|
|
const pool = probes.filter((p) => p.srcServerId === home.id && p.enabled !== false)
|
|
const ranked = jhs
|
|
.map((jh) => {
|
|
const m = pool
|
|
.filter((p) => p.dstServerId === jh.id)
|
|
.filter((p) => {
|
|
const iface = norm(p.srcInterface)
|
|
return !iface || iface === norm(wan.iface)
|
|
})
|
|
const best = m.sort((a, b) => (a.lastPingRttMs ?? 9999) - (b.lastPingRttMs ?? 9999))[0]
|
|
const rtt = best?.lastPingRttMs ?? jh.latency ?? 9999
|
|
return { jh, rtt }
|
|
})
|
|
.sort((a, b) => a.rtt - b.rtt)
|
|
return ranked[0]?.jh ?? null
|
|
}
|
|
|
|
function bestExitForJh(jh: Server, exits: Server[], probes: RouteOptimizerSpeedProbe[]): Server | null {
|
|
const pool = probes.filter((p) => p.enabled !== false)
|
|
const ranked = exits
|
|
.map((ex) => {
|
|
const best = pool
|
|
.filter((p) => (
|
|
(p.srcServerId === jh.id && p.dstServerId === ex.id) ||
|
|
(p.srcServerId === ex.id && p.dstServerId === jh.id)
|
|
))
|
|
.sort((a, b) => (a.lastPingRttMs ?? 9999) - (b.lastPingRttMs ?? 9999))[0]
|
|
const rtt = best?.lastPingRttMs ?? ex.latency ?? 9999
|
|
return { ex, rtt }
|
|
})
|
|
.sort((a, b) => a.rtt - b.rtt)
|
|
return ranked[0]?.ex ?? null
|
|
}
|
|
|
|
function pickHomeJhProbe(
|
|
homeId: string,
|
|
wanIface: string,
|
|
jhId: string,
|
|
probes: RouteOptimizerSpeedProbe[],
|
|
): RouteOptimizerSpeedProbe | null {
|
|
const ifaceNorm = norm(wanIface)
|
|
const list = probes
|
|
.filter((p) => p.enabled !== false)
|
|
.filter((p) => p.srcServerId === homeId && p.dstServerId === jhId)
|
|
.filter((p) => {
|
|
const srcIf = norm(p.srcInterface)
|
|
return !ifaceNorm || !srcIf || srcIf === ifaceNorm
|
|
})
|
|
.sort((a, b) => {
|
|
const aHasPing = a.lastPingRttMs != null ? 1 : 0
|
|
const bHasPing = b.lastPingRttMs != null ? 1 : 0
|
|
if (bHasPing !== aHasPing) return bHasPing - aHasPing
|
|
return (a.lastPingRttMs ?? 9999) - (b.lastPingRttMs ?? 9999)
|
|
})
|
|
return list[0] ?? null
|
|
}
|
|
|
|
function pickJhExitProbe(jhId: string, exitId: string, probes: RouteOptimizerSpeedProbe[]): RouteOptimizerSpeedProbe | null {
|
|
const list = probes
|
|
.filter((p) => p.enabled !== false)
|
|
.filter((p) => (
|
|
(p.srcServerId === jhId && p.dstServerId === exitId) ||
|
|
(p.srcServerId === exitId && p.dstServerId === jhId)
|
|
))
|
|
.sort((a, b) => {
|
|
const aHasPing = a.lastPingRttMs != null ? 1 : 0
|
|
const bHasPing = b.lastPingRttMs != null ? 1 : 0
|
|
if (bHasPing !== aHasPing) return bHasPing - aHasPing
|
|
return (a.lastPingRttMs ?? 9999) - (b.lastPingRttMs ?? 9999)
|
|
})
|
|
return list[0] ?? null
|
|
}
|
|
|
|
function metricValue(primary: number | null | undefined, secondary: number | null | undefined): number | null {
|
|
return primary ?? secondary ?? null
|
|
}
|
|
|
|
export function buildDashboardInternetPath(args: {
|
|
servers: Server[]
|
|
greTunnels: GreTunnel[]
|
|
probes: RouteOptimizerSpeedProbe[]
|
|
filtersRulesets: FiltersRulesetRow[]
|
|
routeLookupByServerId: Record<string, RouteLookupResult | null>
|
|
wanRuntimeByHomeId?: Record<string, HomeWanRuntime | null>
|
|
}): InternetPathViewModel | null {
|
|
const homes = args.servers.filter((s) => s.type === "home-router" && s.enabled)
|
|
const jhs = args.servers.filter((s) => s.type === "jump-host" && s.enabled && s.status !== "offline")
|
|
const exits = args.servers.filter((s) => s.type === "exit-node" && s.enabled && s.status !== "offline")
|
|
const home = homes[0]
|
|
if (!home || !jhs.length || !exits.length) return null
|
|
const runtime = args.wanRuntimeByHomeId?.[home.id] ?? null
|
|
|
|
const optimizerRows: OptimizerApiServer[] = args.servers.map((s) => ({
|
|
id: Number.parseInt(s.id, 10) || 0,
|
|
name: s.name,
|
|
host: s.host,
|
|
type: s.type,
|
|
site: s.site,
|
|
country: s.country,
|
|
enabled: s.enabled,
|
|
status: s.status === "degraded" ? "online" : s.status,
|
|
latency: s.latency,
|
|
model: s.model ?? null,
|
|
wanUplinks: (s.wanUplinks ?? []).map((w) => ({ ...w })),
|
|
}))
|
|
const optimizerData = buildLiveOptimizerData(
|
|
optimizerRows,
|
|
args.filtersRulesets,
|
|
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
|
|
args.probes,
|
|
)
|
|
const homeOptimizer = optimizerData.homes.find((h) => h.home.id === home.id)
|
|
const best = homeOptimizer?.bestRoute ?? null
|
|
|
|
const primaryPath: InternetPathRoute | null = best
|
|
? {
|
|
wanId: best.wan.id,
|
|
jhId: best.jh.id,
|
|
exitId: best.exit.id,
|
|
reason: "Route optimizer / OSPF quality",
|
|
}
|
|
: null
|
|
|
|
const activeWan = chooseActiveWan(
|
|
home,
|
|
args.routeLookupByServerId[home.id] ?? null,
|
|
runtime,
|
|
) ?? syntheticWanForHome(home)
|
|
const currentJh = activeWan ? bestJhForWan(home, activeWan, jhs, args.probes) : null
|
|
const currentExit = currentJh ? bestExitForJh(currentJh, exits, args.probes) : null
|
|
const currentPath: InternetPathRoute | null = activeWan
|
|
? {
|
|
wanId: activeWan.id,
|
|
reason: `Default route 0.0.0.0/0 (main) via ${activeWan.iface || activeWan.ip || "WAN"}`,
|
|
}
|
|
: null
|
|
|
|
const pathState: InternetPathViewModel["pathState"] =
|
|
primaryPath && currentPath
|
|
// Нормальный production-кейс: часть трафика идет через BGP (JH→EN),
|
|
// часть — напрямую в провайдера WAN uplink.
|
|
? "healthy"
|
|
: (!primaryPath && !currentPath ? "unknown" : "degraded")
|
|
|
|
const primaryWan = (home.wanUplinks ?? []).find((w) => w.id === primaryPath?.wanId) ?? null
|
|
const primaryJh = args.servers.find((s) => s.id === primaryPath?.jhId) ?? null
|
|
const primaryEx = args.servers.find((s) => s.id === primaryPath?.exitId) ?? null
|
|
const currentWan = (home.wanUplinks ?? []).find((w) => w.id === currentPath?.wanId) ?? activeWan
|
|
|
|
const primaryHop: InternetPathHop | null =
|
|
primaryWan && primaryJh && primaryEx
|
|
? {
|
|
home,
|
|
wan: primaryWan,
|
|
jumpHost: primaryJh,
|
|
exitNode: primaryEx,
|
|
wanJhMetrics: {
|
|
pingMs: primaryJh.latency,
|
|
dlMbps: primaryWan.maxDl,
|
|
ulMbps: primaryWan.maxUl,
|
|
fromMonitoring: false,
|
|
},
|
|
jhExitMetrics: {
|
|
pingMs: primaryEx.latency,
|
|
dlMbps: null,
|
|
ulMbps: null,
|
|
fromMonitoring: false,
|
|
},
|
|
}
|
|
: null
|
|
|
|
const currentHop: InternetPathHop | null =
|
|
currentWan && currentJh && currentExit
|
|
? {
|
|
home,
|
|
wan: currentWan,
|
|
jumpHost: currentJh,
|
|
exitNode: currentExit,
|
|
wanJhMetrics: (() => {
|
|
const wanJh = buildWanJhEdges(args.servers).find((e) =>
|
|
e.homeId === home.id
|
|
&& e.jhId === currentJh.id
|
|
&& (home.wanUplinks?.[e.wanIdx]?.id ?? "") === currentWan.id,
|
|
)
|
|
const fallback = {
|
|
pingMs: wanJh?.pingMs ?? currentJh.latency,
|
|
dlMbps: wanJh?.dlMbps ?? currentWan.maxDl,
|
|
ulMbps: currentWan.maxUl,
|
|
}
|
|
if (!wanJh) return { ...fallback, fromMonitoring: false }
|
|
const byEdge = assignSpeedProbesToWanJhEdges([wanJh], args.servers, args.probes)
|
|
const sp = byEdge.get(wanJhEdgeMapKey(wanJh))
|
|
const merged = mergeGreMetricsWithSpeedProbe(sp, fallback)
|
|
return {
|
|
pingMs: merged.pingMs,
|
|
dlMbps: metricValue(merged.dlMbps, merged.ulMbps),
|
|
ulMbps: metricValue(merged.ulMbps, merged.dlMbps),
|
|
fromMonitoring: merged.hasSpeedMonitor,
|
|
}
|
|
})(),
|
|
jhExitMetrics: (() => {
|
|
const tunnel = args.greTunnels.find((t) => {
|
|
const from = args.servers.find((s) => s.id === String(t.serverId))
|
|
if (!from) return false
|
|
const to = findServerByGreRemote(args.servers, t.remoteAddress)
|
|
if (!to) return false
|
|
return (
|
|
(from.id === currentJh.id && to.id === currentExit.id)
|
|
|| (from.id === currentExit.id && to.id === currentJh.id)
|
|
)
|
|
})
|
|
const fallback = tunnel
|
|
? greTunnelProbe(tunnel)
|
|
: { pingMs: currentExit.latency, dlMbps: null, ulMbps: null }
|
|
if (!tunnel) {
|
|
return {
|
|
pingMs: fallback.pingMs,
|
|
dlMbps: fallback.dlMbps,
|
|
ulMbps: fallback.ulMbps,
|
|
fromMonitoring: false,
|
|
}
|
|
}
|
|
const fromServer = args.servers.find((s) => s.id === String(tunnel.serverId))
|
|
const toServer = fromServer ? findServerByGreRemote(args.servers, tunnel.remoteAddress) : undefined
|
|
if (!fromServer || !toServer) {
|
|
return {
|
|
pingMs: fallback.pingMs,
|
|
dlMbps: fallback.dlMbps,
|
|
ulMbps: fallback.ulMbps,
|
|
fromMonitoring: false,
|
|
}
|
|
}
|
|
const byTunnel = assignSpeedProbesToGreTunnels(
|
|
[{ tunnel, fromServer, toServer }],
|
|
args.probes,
|
|
)
|
|
const sp = byTunnel.get(tunnel.id)
|
|
const merged = mergeGreMetricsWithSpeedProbe(sp, fallback)
|
|
return {
|
|
pingMs: merged.pingMs,
|
|
dlMbps: metricValue(merged.dlMbps, merged.ulMbps),
|
|
ulMbps: metricValue(merged.ulMbps, merged.dlMbps),
|
|
fromMonitoring: merged.hasSpeedMonitor,
|
|
}
|
|
})(),
|
|
}
|
|
: null
|
|
|
|
return {
|
|
homeRouter: home,
|
|
activeWanUplink: activeWan,
|
|
fallbackJumpHost: currentJh ?? primaryJh ?? jhs[0] ?? null,
|
|
fallbackExitNode: currentExit ?? primaryEx ?? exits[0] ?? null,
|
|
primaryHop,
|
|
currentHop,
|
|
primaryPath,
|
|
currentPath,
|
|
pathState,
|
|
directWan: {
|
|
enabled: Boolean(runtime?.defaultGateway || args.routeLookupByServerId[home.id]?.gateway),
|
|
gateway: runtime?.defaultGateway ?? args.routeLookupByServerId[home.id]?.gateway ?? null,
|
|
iface: runtime?.defaultInterface ?? activeWan.iface ?? null,
|
|
leasedIp:
|
|
runtime?.uplinks.find((u) => u.isDefault)?.leasedIp
|
|
?? runtime?.uplinks.find((u) => norm(u.iface) === norm(activeWan.iface))?.leasedIp
|
|
?? null,
|
|
provider:
|
|
runtime?.uplinks.find((u) => u.isDefault)?.isp
|
|
?? runtime?.uplinks.find((u) => norm(u.iface) === norm(activeWan.iface))?.isp
|
|
?? activeWan.isp
|
|
?? null,
|
|
},
|
|
}
|
|
}
|
|
|
|
export async function resolveDefaultRouteLookup(
|
|
apiFetch: <T>(path: string, init?: RequestInit) => Promise<T>,
|
|
serverId: string,
|
|
): Promise<RouteLookupResult | null> {
|
|
try {
|
|
const payload = await apiFetch<{ output?: string }>(`/api/servers/${serverId}/probes/run`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
tool: "route",
|
|
target: INTERNET_TARGET,
|
|
}),
|
|
})
|
|
return parseRouteLookupOutput(payload.output ?? "")
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|