This commit is contained in:
Denozordec
2026-05-03 11:16:07 +07:00
parent ce00c4c671
commit bdb9b72fac
66 changed files with 9553 additions and 1547 deletions
+310
View File
@@ -0,0 +1,310 @@
import type { GreTunnel, Server } from "@/lib/data"
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
}
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, string>,
): 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, string>,
): 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<string, string>,
): 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 и т.п.).
*/
export function assignSpeedProbesToGreTunnels(
edges: GreTunnelProbeEdge[],
probes: GreSpeedProbeSnapshot[],
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): ReadonlyMap<string, GreSpeedProbeSnapshot | undefined> {
const result = new Map<string, GreSpeedProbeSnapshot | undefined>()
const byPair = new Map<string, GreTunnelProbeEdge[]>()
for (const e of edges) {
const k = `${String(e.fromServer.id)}\t${String(e.toServer.id)}`
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<string, string>,
): Map<string, GreSpeedProbeSnapshot | undefined> {
const out = new Map<string, GreSpeedProbeSnapshot | undefined>()
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<string, GreSpeedProbeSnapshot> | null = null
function dfs(i: number, used: Set<string>, cur: Map<string, GreSpeedProbeSnapshot>) {
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<string, GreSpeedProbeSnapshot> | 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<string>()
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<string, string>,
): 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 }
}