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 wanRuntimeByHomeId?: Record }): 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: (path: string, init?: RequestInit) => Promise, serverId: string, ): Promise { 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 } }