Includes current frontend and backend work in progress and removes generated artifacts from tracking to keep the repository clean for дальнейшая разработка. Co-authored-by: Cursor <cursoragent@cursor.com>
490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
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<Server, "id">, b: Pick<Server, "id">): 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, 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 и т.п.).
|
||
*
|
||
* Пара узлов **неориентированная**: туннель в БД на HR→MSK и зеркальная запись MSK→HR
|
||
* попадают в одну группу — иначе обе стороны независимо выбирают одни и те же пробы
|
||
* и бейджи дублируются на разных линиях карты.
|
||
*/
|
||
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 = 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<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 }
|
||
}
|
||
|
||
// ── WAN satellite → JumpHost: speed-пробы с src = Home Router и выбранным WAN-iface ──
|
||
|
||
export function wanJhEdgeMapKey(edge: Pick<WanJhEdge, "homeId" | "wanIdx" | "jhId">): string {
|
||
return `${edge.homeId}\t${edge.wanIdx}\t${edge.jhId}`
|
||
}
|
||
|
||
function scoreSpeedProbeForWanJhEdge(
|
||
p: GreSpeedProbeSnapshot,
|
||
edge: Pick<WanJhEdge, "homeId" | "wanIdx" | "jhId">,
|
||
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<string, GreSpeedProbeSnapshot | undefined> {
|
||
const out = new Map<string, GreSpeedProbeSnapshot | undefined>()
|
||
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<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(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<string, GreSpeedProbeSnapshot> | 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<string>()
|
||
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<string, GreSpeedProbeSnapshot | undefined> {
|
||
const result = new Map<string, GreSpeedProbeSnapshot | undefined>()
|
||
const homeById = new Map(servers.filter((s) => s.type === "home-router").map((s) => [s.id, s] as const))
|
||
|
||
const byPair = new Map<string, WanJhEdge[]>()
|
||
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
|
||
}
|