368 lines
13 KiB
TypeScript
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))
|
|
}
|