Files
MikrotikManager/lib/map-gre-speed-probe.ts
T
DenozordecandCursor 5f31bb47fb chore: synchronize pending app/backend updates and repository hygiene
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>
2026-05-07 12:29:04 +07:00

490 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}