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