Files
MikrotikManager/lib/dashboard-internet-path.ts
T

453 lines
15 KiB
TypeScript

import {readProbeOutput} from "@/lib/probe-response"
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 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 === "unknown" ? null : 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 observedRoute=Boolean(runtime?.defaultGateway||args.routeLookupByServerId[home.id]?.gateway)
const routeKnown=runtime!=null&&args.routeLookupByServerId[home.id]!=null
const activeWan = observedRoute ? chooseActiveWan(
home,
args.routeLookupByServerId[home.id] ?? null,
runtime,
) : null
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"] =
!routeKnown||home.status==="unknown"||(currentJh?.status==="unknown")||(currentExit?.status==="unknown")?"unknown":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> {
const payload = await apiFetch<unknown>(`/api/servers/${serverId}/probes/run`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tool: "route",
target: INTERNET_TARGET,
}),
})
return parseRouteLookupOutput(readProbeOutput(payload))
}