import type { GreTunnel, Server } from "@/lib/data" import type { WanJhEdge } from "@/lib/network-map-layout" import { formatGreOuterForDisplay, normalizeGreEndpointAddr, wanUplinkIpMatchesGreOuter, } from "@/lib/gre-endpoint-resolve" /** Поля speed-пробы из мониторинга (Мониторинг → скорость), нужные для GRE на карте. */ export interface GreSpeedProbeSnapshot { id: string srcServerId: string dstServerId: string enabled?: boolean srcInterface?: string dstInterface?: string lastTxAvgMbps?: number | null lastRxAvgMbps?: number | null lastPingRttMs?: number | null } export type GreTunnelProbeEdge = { tunnel: GreTunnel fromServer: Server toServer: Server } /** Неориентированная пара узлов: один ключ для HR→MSK и MSK→HR (две строки GRE в БД). */ export function canonicalGreServerPairKey(a: Pick, b: Pick): string { const x = String(a.id) const y = String(b.id) return x <= y ? `${x}\t${y}` : `${y}\t${x}` } function normIface(i?: string | null): string { return (i ?? "").trim().toLowerCase() } /** WAN-интерфейс узла, чей внешний IP совпадает с GRE outer (endpoint каталога). */ export function ifaceNameForGreOuterIp( server: Server, outerIp: string, resolvedIpv4ByHost?: ReadonlyMap, ): string | null { const r = normalizeGreEndpointAddr(outerIp) if (!r || r === "0.0.0.0") return null for (const w of server.wanUplinks ?? []) { if (wanUplinkIpMatchesGreOuter(w.ip, outerIp, resolvedIpv4ByHost)) return w.iface.trim() } return null } /** Короткая подпись для бейджа: внешние IP и имена WAN при наличии в каталоге. */ export function greOuterSummaryLine( tunnel: GreTunnel, fromServer: Server, toServer: Server, resolvedIpv4ByHost?: ReadonlyMap, ): string { const lo = formatGreOuterForDisplay(tunnel.localAddress, resolvedIpv4ByHost) || "auto" const ro = formatGreOuterForDisplay(tunnel.remoteAddress, resolvedIpv4ByHost) || "?" const li = ifaceNameForGreOuterIp(fromServer, tunnel.localAddress, resolvedIpv4ByHost) const ri = ifaceNameForGreOuterIp(toServer, tunnel.remoteAddress, resolvedIpv4ByHost) const ipPart = `${lo}→${ro}` if (li || ri) return `${ipPart} · ${li ?? "?"}→${ri ?? "?"}` return ipPart } function scoreProbeForGreTunnel( p: GreSpeedProbeSnapshot, tunnel: GreTunnel, fromServer: Server, toServer: Server, resolvedIpv4ByHost?: ReadonlyMap, ): number { const pf = String(p.srcServerId) const pt = String(p.dstServerId) const fid = String(fromServer.id) const tid = String(toServer.id) const forward = pf === fid && pt === tid const backward = pf === tid && pt === fid if (!forward && !backward) return -1 const si = normIface(p.srcInterface) const di = normIface(p.dstInterface) let s = 0 if (forward) { const srcW = ifaceNameForGreOuterIp(fromServer, tunnel.localAddress, resolvedIpv4ByHost) const dstW = ifaceNameForGreOuterIp(toServer, tunnel.remoteAddress, resolvedIpv4ByHost) const srcM = !!(srcW && si === normIface(srcW)) const dstM = !!(dstW && di === normIface(dstW)) if (srcM) s += 52 if (dstM) s += 52 if (srcM && dstM) s += 34 if (!srcW && dstW && di === normIface(dstW)) s += 45 if (!dstW && srcW && si === normIface(srcW)) s += 45 } else { const srcW = ifaceNameForGreOuterIp(toServer, tunnel.remoteAddress, resolvedIpv4ByHost) const dstW = ifaceNameForGreOuterIp(fromServer, tunnel.localAddress, resolvedIpv4ByHost) const srcM = !!(srcW && si === normIface(srcW)) const dstM = !!(dstW && di === normIface(dstW)) if (srcM) s += 52 if (dstM) s += 52 if (srcM && dstM) s += 34 if (!srcW && dstW && di === normIface(dstW)) s += 45 if (!dstW && srcW && si === normIface(srcW)) s += 45 } if (p.lastPingRttMs != null || p.lastTxAvgMbps != null || p.lastRxAvgMbps != null) s += 4 return s } function probesForServerPair( probes: GreSpeedProbeSnapshot[], fromServer: Server, toServer: Server, ): GreSpeedProbeSnapshot[] { const fid = String(fromServer.id) const tid = String(toServer.id) return probes.filter((p) => { if (p.enabled === false) return false const pf = String(p.srcServerId) const pt = String(p.dstServerId) return (pf === fid && pt === tid) || (pf === tid && pt === fid) }) } /** * Для всех GRE на карте назначает speed-пробы без повторного использования одной пробы * на два разных туннеля между одной и той же парой узлов (RT / MTS и т.п.). * * Пара узлов **неориентированная**: туннель в БД на HR→MSK и зеркальная запись MSK→HR * попадают в одну группу — иначе обе стороны независимо выбирают одни и те же пробы * и бейджи дублируются на разных линиях карты. */ export function assignSpeedProbesToGreTunnels( edges: GreTunnelProbeEdge[], probes: GreSpeedProbeSnapshot[], resolvedIpv4ByHost?: ReadonlyMap, ): ReadonlyMap { const result = new Map() const byPair = new Map() for (const e of edges) { const k = canonicalGreServerPairKey(e.fromServer, e.toServer) const arr = byPair.get(k) ?? [] arr.push(e) byPair.set(k, arr) } for (const group of byPair.values()) { const assigned = assignProbesWithinPair(group, probes, resolvedIpv4ByHost) for (const [tunnelId, sp] of assigned) result.set(tunnelId, sp) } return result } function assignProbesWithinPair( edges: GreTunnelProbeEdge[], allProbes: GreSpeedProbeSnapshot[], resolved?: ReadonlyMap, ): Map { const out = new Map() if (edges.length === 0) return out const E = [...edges].sort((a, b) => a.tunnel.id.localeCompare(b.tunnel.id)) const pairProbes = probesForServerPair(allProbes, E[0].fromServer, E[0].toServer) if (E.length === 1) { out.set( E[0].tunnel.id, findSpeedProbeForGreEdge(E[0].tunnel, E[0].fromServer, E[0].toServer, allProbes, resolved), ) return out } const n = E.length const m = pairProbes.length if (m === 0) { for (const e of E) out.set(e.tunnel.id, undefined) return out } // Полное назначение: каждому туннелю своя проба, максимум суммы score (RT и MTS не делят одну пробу). let bestSum = -Infinity let bestAssign: Map | null = null function dfs(i: number, used: Set, cur: Map) { if (i === n) { let sum = 0 for (const e of E) { const p = cur.get(e.tunnel.id) if (!p) return sum += scoreProbeForGreTunnel(p, e.tunnel, e.fromServer, e.toServer, resolved) } if (sum > bestSum) { bestSum = sum bestAssign = new Map(cur) } return } const edge = E[i]! for (const p of pairProbes) { if (used.has(p.id)) continue if (scoreProbeForGreTunnel(p, edge.tunnel, edge.fromServer, edge.toServer, resolved) < 0) continue used.add(p.id) cur.set(edge.tunnel.id, p) dfs(i + 1, used, cur) cur.delete(edge.tunnel.id) used.delete(p.id) } } // Перебор инъекций только при небольшом n — иначе сразу жадный алгоритм. if (n <= 7 && n <= m) dfs(0, new Set(), new Map()) /** TS не видит присваивание из замыкания dfs — явное приведение. */ const inject = bestAssign as Map | null if (inject !== null && inject.size === n) { for (const ed of E) out.set(ed.tunnel.id, inject.get(ed.tunnel.id)) return out } // Недостаточно проб или нет полного матчинга — жадно по убыванию score, без повторов. const used = new Set() const remaining = new Set(E.map((e) => e.tunnel.id)) while (remaining.size > 0) { let pickEdge: GreTunnelProbeEdge | undefined let pickProbe: GreSpeedProbeSnapshot | undefined let pickScore = -Infinity for (const tid of remaining) { const e = E.find((x) => x.tunnel.id === tid)! for (const p of pairProbes) { if (used.has(p.id)) continue const sc = scoreProbeForGreTunnel(p, e.tunnel, e.fromServer, e.toServer, resolved) if (sc < 0) continue if ( sc > pickScore || (sc === pickScore && pickProbe != null && p.id.localeCompare(pickProbe.id) < 0) ) { pickScore = sc pickEdge = e pickProbe = p } } } if (!pickEdge || !pickProbe) { for (const tid of remaining) out.set(tid, undefined) break } out.set(pickEdge.tunnel.id, pickProbe) used.add(pickProbe.id) remaining.delete(pickEdge.tunnel.id) } return out } /** * Подбирает speed-пробу для одного GRE (без учёта соседних туннелей той же пары узлов). * Для нескольких туннелей между теми же серверами используйте assignSpeedProbesToGreTunnels. */ export function findSpeedProbeForGreEdge( tunnel: GreTunnel, fromServer: Server, toServer: Server, probes: GreSpeedProbeSnapshot[], resolvedIpv4ByHost?: ReadonlyMap, ): GreSpeedProbeSnapshot | undefined { const cand = probes.filter( (p) => p.enabled !== false && scoreProbeForGreTunnel(p, tunnel, fromServer, toServer, resolvedIpv4ByHost) >= 0, ) if (cand.length === 0) return undefined let best = cand[0]! let bestScore = scoreProbeForGreTunnel(best, tunnel, fromServer, toServer, resolvedIpv4ByHost) for (let i = 1; i < cand.length; i++) { const p = cand[i]! const sc = scoreProbeForGreTunnel(p, tunnel, fromServer, toServer, resolvedIpv4ByHost) if (sc > bestScore) { best = p bestScore = sc continue } if (sc === bestScore) { const hasData = (x: GreSpeedProbeSnapshot) => x.lastPingRttMs != null || x.lastTxAvgMbps != null || x.lastRxAvgMbps != null if (hasData(p) && !hasData(best)) best = p } } return best } export function mergeGreMetricsWithSpeedProbe( sp: GreSpeedProbeSnapshot | undefined, fallback: { pingMs: number | null; dlMbps: number | null; ulMbps: number | null }, ): { pingMs: number | null dlMbps: number | null ulMbps: number | null hasSpeedMonitor: boolean } { const hasSpeedMonitor = sp != null && (sp.lastPingRttMs != null || sp.lastTxAvgMbps != null || sp.lastRxAvgMbps != null) const pingMs = sp?.lastPingRttMs ?? fallback.pingMs const dlMbps = sp?.lastTxAvgMbps != null ? Math.round(sp.lastTxAvgMbps) : fallback.dlMbps const ulMbps = sp?.lastRxAvgMbps != null ? Math.round(sp.lastRxAvgMbps) : fallback.ulMbps return { pingMs, dlMbps, ulMbps, hasSpeedMonitor } } // ── WAN satellite → JumpHost: speed-пробы с src = Home Router и выбранным WAN-iface ── export function wanJhEdgeMapKey(edge: Pick): string { return `${edge.homeId}\t${edge.wanIdx}\t${edge.jhId}` } function scoreSpeedProbeForWanJhEdge( p: GreSpeedProbeSnapshot, edge: Pick, home: Server, ): number { if (String(p.srcServerId) !== edge.homeId || String(p.dstServerId) !== edge.jhId) return -1 if (p.enabled === false) return -1 const wanIface = normIface(home.wanUplinks?.[edge.wanIdx]?.iface) const si = normIface(p.srcInterface) if (si && wanIface && si !== wanIface) return -1 let s = 0 if (si && wanIface && si === wanIface) s += 100 else if (!si && wanIface) s += 35 else if (!wanIface && si) s += 15 else if (!si && !wanIface) s += 5 if (p.lastPingRttMs != null || p.lastTxAvgMbps != null || p.lastRxAvgMbps != null) s += 6 return s } function probesHomeToJh(allProbes: GreSpeedProbeSnapshot[], homeId: string, jhId: string): GreSpeedProbeSnapshot[] { return allProbes.filter((p) => { if (p.enabled === false) return false return String(p.srcServerId) === homeId && String(p.dstServerId) === jhId }) } function assignSpeedProbesWithinWanJhGroup( edges: WanJhEdge[], home: Server, allProbes: GreSpeedProbeSnapshot[], ): Map { const out = new Map() if (edges.length === 0) return out const E = [...edges].sort((a, b) => a.wanIdx - b.wanIdx || a.jhId.localeCompare(b.jhId)) const pairProbes = probesHomeToJh(allProbes, E[0].homeId, E[0].jhId) if (E.length === 1) { let best: GreSpeedProbeSnapshot | undefined let bestS = -1 for (const p of pairProbes) { const sc = scoreSpeedProbeForWanJhEdge(p, E[0], home) if (sc > bestS) { bestS = sc best = p } } out.set(wanJhEdgeMapKey(E[0]), best != null && bestS >= 0 ? best : undefined) return out } const n = E.length const m = pairProbes.length if (m === 0) { for (const e of E) out.set(wanJhEdgeMapKey(e), undefined) return out } let bestSum = -Infinity let bestAssign: Map | null = null function dfs(i: number, used: Set, cur: Map) { if (i === n) { let sum = 0 for (const e of E) { const p = cur.get(wanJhEdgeMapKey(e)) if (!p) return sum += scoreSpeedProbeForWanJhEdge(p, e, home) } if (sum > bestSum) { bestSum = sum bestAssign = new Map(cur) } return } const edge = E[i]! const slotKey = wanJhEdgeMapKey(edge) for (const p of pairProbes) { if (used.has(p.id)) continue if (scoreSpeedProbeForWanJhEdge(p, edge, home) < 0) continue used.add(p.id) cur.set(slotKey, p) dfs(i + 1, used, cur) cur.delete(slotKey) used.delete(p.id) } } if (n <= 7 && n <= m) dfs(0, new Set(), new Map()) const inject = bestAssign as Map | null if (inject !== null && inject.size === n) { for (const ed of E) out.set(wanJhEdgeMapKey(ed), inject.get(wanJhEdgeMapKey(ed))) return out } const used = new Set() const remaining = new Set(E.map((e) => wanJhEdgeMapKey(e))) while (remaining.size > 0) { let pickEdge: WanJhEdge | undefined let pickProbe: GreSpeedProbeSnapshot | undefined let pickScore = -Infinity for (const key of remaining) { const e = E.find((x) => wanJhEdgeMapKey(x) === key)! for (const p of pairProbes) { if (used.has(p.id)) continue const sc = scoreSpeedProbeForWanJhEdge(p, e, home) if (sc < 0) continue if ( sc > pickScore || (sc === pickScore && pickProbe != null && p.id.localeCompare(pickProbe.id) < 0) ) { pickScore = sc pickEdge = e pickProbe = p } } } if (!pickEdge || !pickProbe) { for (const key of remaining) out.set(key, undefined) break } out.set(wanJhEdgeMapKey(pickEdge), pickProbe) used.add(pickProbe.id) remaining.delete(wanJhEdgeMapKey(pickEdge)) } return out } /** * Назначает speed-пробы сегментам WAN→JH (Home → спутник → JH): по паре серверов и iface источника, * без повторного использования одной пробы на два WAN одного home→jh (RT / MTS). */ export function assignSpeedProbesToWanJhEdges( edges: WanJhEdge[], servers: Server[], probes: GreSpeedProbeSnapshot[], ): ReadonlyMap { const result = new Map() const homeById = new Map(servers.filter((s) => s.type === "home-router").map((s) => [s.id, s] as const)) const byPair = new Map() for (const e of edges) { const k = `${e.homeId}\t${e.jhId}` const arr = byPair.get(k) ?? [] arr.push(e) byPair.set(k, arr) } for (const group of byPair.values()) { const home = homeById.get(group[0].homeId) if (!home) { for (const e of group) result.set(wanJhEdgeMapKey(e), undefined) continue } const assigned = assignSpeedProbesWithinWanJhGroup(group, home, probes) for (const [k, sp] of assigned) result.set(k, sp) } return result }