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 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 === "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: (path: string, init?: RequestInit) => Promise, serverId: string, ): Promise { const payload = await apiFetch(`/api/servers/${serverId}/probes/run`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tool: "route", target: INTERNET_TARGET, }), }) return parseRouteLookupOutput(readProbeOutput(payload)) }