Files
MikrotikManager/lib/dashboard-internet-path.ts
T
Denozordec b9a75b6831 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.
2026-05-08 00:40:41 +07:00

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
}
}