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

368 lines
13 KiB
TypeScript

import {readProbeOutput} from "@/lib/probe-response"
import type { GreTunnel, Server, WanUplink } from "@/lib/data"
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
}>
}
type DashboardPathProbe = RouteOptimizerSpeedProbe & {lastRunAt?: string | null; lastStatus?: string | null; lastError?: string | null; lastPingError?: string | null}
export function dashboardPathMetrics(probe: DashboardPathProbe | null, reverse = false): InternetPathHop["wanJhMetrics"] {
const dated=(time: string | null | undefined)=>Boolean(time)&&Number.isFinite(Date.parse(time!))
const known=(value: number | null | undefined)=>typeof value==="number"&&Number.isFinite(value)&&value>=0?value:null
const ping=probe?.enabled && dated(probe.lastPingAt) && (probe.lastPingError===null||probe.lastPingError==="") ? known(probe.lastPingRttMs) : null
const speed=probe?.enabled && dated(probe.lastRunAt) && probe.lastStatus==="done" && (probe.lastError===null||probe.lastError==="")
const dl=speed?known(reverse?probe.lastTxAvgMbps:probe.lastRxAvgMbps):null
const ul=speed?known(reverse?probe.lastRxAvgMbps:probe.lastTxAvgMbps):null
return {pingMs:ping,dlMbps:dl,ulMbps:ul,fromMonitoring:ping!==null||dl!==null||ul!==null}
}
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
}
}
// 2) Fallback: route-lookup
const gw = norm(lookup?.gateway)
if (gw) {
const byIface = wans.find((w) => Boolean(norm(w.iface)) && (norm(w.iface) === gw || gw.includes("%"+norm(w.iface))))
if (byIface) return byIface
const byIp = wans.find((w) => Boolean(norm(w.ip)) && (norm(w.ip) === gw || gw.startsWith(`${norm(w.ip)}%`)))
if (byIp) return byIp
}
return null
}
function bestJhForWan(
home: Server,
wan: WanUplink,
jhs: Server[],
probes: DashboardPathProbe[],
): 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: DashboardPathProbe[]): 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: DashboardPathProbe[],
): 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: DashboardPathProbe[]): 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
}
export function buildDashboardInternetPath(args: {
servers: Server[]
greTunnels: GreTunnel[]
probes: DashboardPathProbe[]
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: dashboardPathMetrics(pickHomeJhProbe(home.id, primaryWan.iface, primaryJh.id, args.probes)),
jhExitMetrics: (()=>{const p=pickJhExitProbe(primaryJh.id,primaryEx.id,args.probes);return dashboardPathMetrics(p,p?.srcServerId!==primaryJh.id)})(),
}
: null
const currentHop: InternetPathHop | null =
currentWan && currentJh && currentExit
? {
home,
wan: currentWan,
jumpHost: currentJh,
exitNode: currentExit,
wanJhMetrics: dashboardPathMetrics(pickHomeJhProbe(home.id,currentWan.iface,currentJh.id,args.probes)),
jhExitMetrics: (()=>{const p=pickJhExitProbe(currentJh.id,currentExit.id,args.probes);return dashboardPathMetrics(p,p?.srcServerId!==currentJh.id)})(),
}
: 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))
}