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>
608 lines
22 KiB
TypeScript
608 lines
22 KiB
TypeScript
/**
|
||
* Раскладка узлов карты сети и синтетические WAN→JH рёбра по данным серверов (как в оптимизаторе).
|
||
*/
|
||
|
||
import type { GreTunnel } from "@/lib/data"
|
||
import type { Server, ServerType } from "@/lib/data"
|
||
import {
|
||
greOuterComparableIps,
|
||
normalizeGreEndpointAddr,
|
||
wanUplinkIpMatchesGreOuter,
|
||
} from "@/lib/gre-endpoint-resolve"
|
||
|
||
/** Визуализация метрик на GRE (нет отдельного API замеров для карты). */
|
||
export interface TunnelProbe {
|
||
pingMs: number | null
|
||
dlMbps: number | null
|
||
ulMbps: number | null
|
||
}
|
||
|
||
export function greTunnelProbe(t: GreTunnel): TunnelProbe {
|
||
if (t.status === "down" || !t.enabled)
|
||
return { pingMs: null, dlMbps: null, ulMbps: null }
|
||
const h = t.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0)
|
||
if (t.status === "degraded") {
|
||
return {
|
||
pingMs: 35 + (h % 80),
|
||
dlMbps: 40 + (h % 120),
|
||
ulMbps: 35 + (h % 100),
|
||
}
|
||
}
|
||
return {
|
||
pingMs: 6 + (h % 30),
|
||
dlMbps: 180 + (h % 520),
|
||
ulMbps: 120 + (h % 400),
|
||
}
|
||
}
|
||
|
||
const W = 1060
|
||
const H = 580
|
||
const MARGIN = 72
|
||
|
||
/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
|
||
export const NETWORK_MAP_PIPELINE_Y = 300
|
||
|
||
/**
|
||
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
|
||
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
|
||
*/
|
||
export const NETWORK_MAP_LAYOUT_REVISION = 5
|
||
|
||
export interface WanJhEdge {
|
||
homeId: string
|
||
wanIdx: number
|
||
jhId: string
|
||
/** Сумма `latency` домашнего узла и JH из каталога (как в /servers); null если нет обоих замеров. */
|
||
pingMs: number | null
|
||
dlMbps: number
|
||
active: boolean
|
||
}
|
||
|
||
export interface NetworkMapSrvRes {
|
||
cpu: number
|
||
cpuHistory: number[]
|
||
ramUsed: number
|
||
ramTotal: number
|
||
hddUsed: number
|
||
hddTotal: number
|
||
uptimeSeconds: number
|
||
temp?: number
|
||
/** CPU/RAM/uptime сняты с последнего снапшота опроса; HDD в API нет — по-прежнему условная шкала. */
|
||
fromPoll?: boolean
|
||
}
|
||
|
||
/** Uptime из поля resource «1w2d3h4m5s» (как на бэкенде). */
|
||
export function parseRouterosUptimeToSeconds(raw: string | null | undefined): number {
|
||
if (!raw) return 0
|
||
let total = 0
|
||
for (const m of raw.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) {
|
||
const n = Number.parseInt(m[1], 10)
|
||
if (!Number.isFinite(n)) continue
|
||
switch (m[2]) {
|
||
case "w":
|
||
total += n * 604800
|
||
break
|
||
case "d":
|
||
total += n * 86400
|
||
break
|
||
case "h":
|
||
total += n * 3600
|
||
break
|
||
case "m":
|
||
total += n * 60
|
||
break
|
||
case "s":
|
||
total += n
|
||
break
|
||
}
|
||
}
|
||
return total
|
||
}
|
||
|
||
/** Равномерно по отрезку [xMin, xMax] — несколько узлов в одной колонке. */
|
||
function spreadInSegment(count: number, index: number, xMin: number, xMax: number): number {
|
||
if (count <= 0) return (xMin + xMax) / 2
|
||
if (count === 1) return (xMin + xMax) / 2
|
||
return xMin + (index / (count - 1)) * (xMax - xMin)
|
||
}
|
||
|
||
function clamp(n: number, min: number, max: number): number {
|
||
return Math.max(min, Math.min(max, n))
|
||
}
|
||
|
||
function resolveLaneCollisions(
|
||
ids: string[],
|
||
nodePos: Record<string, { x: number; y: number }>,
|
||
minY: number,
|
||
maxY: number,
|
||
minGap: number,
|
||
): void {
|
||
if (ids.length <= 1) {
|
||
if (ids.length === 1) {
|
||
const id = ids[0]
|
||
const p = nodePos[id]
|
||
if (p) p.y = clamp(p.y, minY, maxY)
|
||
}
|
||
return
|
||
}
|
||
const sorted = ids
|
||
.map((id) => ({ id, y: nodePos[id]?.y ?? (minY + maxY) / 2 }))
|
||
.sort((a, b) => a.y - b.y)
|
||
const n = sorted.length
|
||
const maxSpan = maxY - minY
|
||
const gap = Math.min(minGap, maxSpan / (n - 1))
|
||
const center = sorted.reduce((acc, s) => acc + s.y, 0) / n
|
||
const span = gap * (n - 1)
|
||
const start = clamp(center - span / 2, minY, maxY - span)
|
||
for (let i = 0; i < n; i++) {
|
||
const p = nodePos[sorted[i].id]
|
||
if (!p) continue
|
||
p.y = start + i * gap
|
||
}
|
||
}
|
||
|
||
/** Детерминированные координаты узлов и «спутников» WAN под home-router. */
|
||
export function computeNetworkMapLayout(servers: Server[]): {
|
||
nodePos: Record<string, { x: number; y: number }>
|
||
wanSatPos: Record<string, { x: number; y: number }[]>
|
||
} {
|
||
const nodePos: Record<string, { x: number; y: number }> = {}
|
||
const wanSatPos: Record<string, { x: number; y: number }[]> = {}
|
||
|
||
const span = W - 2 * MARGIN
|
||
const laneGap = Math.min(44, span * 0.04)
|
||
const laneW = (span - 2 * laneGap) / 3
|
||
|
||
const laneOrder: ServerType[] = ["home-router", "jump-host", "exit-node"]
|
||
const laneByType = new Map<ServerType, Server[]>()
|
||
const minNodeY = MARGIN + 70
|
||
const maxNodeY = H - 72
|
||
const laneMinGap = 110
|
||
const typeYOffset: Record<ServerType, number> = {
|
||
"home-router": -14,
|
||
"jump-host": 0,
|
||
"exit-node": 14,
|
||
}
|
||
const siteWeight = (s: Server): number => {
|
||
const typeBoost = s.type === "jump-host" ? 3 : s.type === "home-router" ? 2 : 1
|
||
const statusBoost = s.status === "online" ? 2 : s.status === "degraded" ? 1 : 0
|
||
return typeBoost + statusBoost
|
||
}
|
||
const siteNames = [
|
||
...new Set(
|
||
servers
|
||
.map((s) => s.site?.trim())
|
||
.filter((v): v is string => Boolean(v)),
|
||
),
|
||
]
|
||
siteNames.sort((a, b) => {
|
||
const wa = servers.filter((s) => s.site === a).reduce((acc, s) => acc + siteWeight(s), 0)
|
||
const wb = servers.filter((s) => s.site === b).reduce((acc, s) => acc + siteWeight(s), 0)
|
||
if (wb !== wa) return wb - wa
|
||
return a.localeCompare(b)
|
||
})
|
||
const siteY = new Map<string, number>()
|
||
siteNames.forEach((site, i) => {
|
||
siteY.set(site, spreadInSegment(siteNames.length, i, minNodeY, maxNodeY))
|
||
})
|
||
const fallbackY = (minNodeY + maxNodeY) / 2
|
||
let xLane = MARGIN
|
||
for (const type of laneOrder) {
|
||
const group = servers
|
||
.filter((s) => s.type === type)
|
||
.slice()
|
||
.sort((a, b) => `${a.site}|${a.name}|${a.id}`.localeCompare(`${b.site}|${b.name}|${b.id}`))
|
||
laneByType.set(type, group)
|
||
const xMin = xLane
|
||
const xMax = xLane + laneW
|
||
const laneCenterX = (xMin + xMax) / 2
|
||
group.forEach((s, i) => {
|
||
const baseline = siteY.get(s.site) ?? fallbackY
|
||
const siblings = group.filter((g) => g.site === s.site)
|
||
const sibIdx = siblings.findIndex((g) => g.id === s.id)
|
||
const spread = siblings.length <= 1 ? 0 : (sibIdx - (siblings.length - 1) / 2) * 34
|
||
nodePos[s.id] = {
|
||
// Жёсткие колонки: Gateway слева, JH по центру, EN справа.
|
||
x: laneCenterX,
|
||
y: clamp(baseline + typeYOffset[type] + spread, minNodeY, maxNodeY),
|
||
}
|
||
})
|
||
xLane += laneW + laneGap
|
||
}
|
||
|
||
const homes = laneByType.get("home-router") ?? []
|
||
const jhs = laneByType.get("jump-host") ?? []
|
||
const exits = laneByType.get("exit-node") ?? []
|
||
resolveLaneCollisions(homes.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
|
||
resolveLaneCollisions(jhs.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
|
||
resolveLaneCollisions(exits.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
|
||
|
||
const hr = homes
|
||
/** Центр колонки jump-host — спутники WAN ставим на X между HR и JH (как на схеме «шлюз → провайдеры → JH»). */
|
||
const jhColumnCenterX = MARGIN + laneW + laneGap + laneW / 2
|
||
|
||
for (const r of hr) {
|
||
const wans = r.wanUplinks?.length ? r.wanUplinks : []
|
||
const base = nodePos[r.id] ?? { x: W / 2, y: NETWORK_MAP_PIPELINE_Y }
|
||
const n = wans.length
|
||
const satX = (base.x + jhColumnCenterX) / 2
|
||
const rowY = base.y
|
||
const maxV = H - 2 * MARGIN - 100
|
||
const vGap =
|
||
n <= 1 ? 0 : Math.min(56, maxV / Math.max(1, n - 1))
|
||
const positions: { x: number; y: number }[] = []
|
||
for (let i = 0; i < n; i++) {
|
||
const rawY = n === 1 ? rowY : rowY + (i - (n - 1) / 2) * vGap
|
||
const y = Math.max(MARGIN + 48, Math.min(H - 72, rawY))
|
||
positions.push({ x: satX, y })
|
||
}
|
||
wanSatPos[r.id] = positions
|
||
}
|
||
|
||
return { nodePos, wanSatPos }
|
||
}
|
||
|
||
/**
|
||
* Суммарная задержка «дом → JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы.
|
||
* Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов.
|
||
*/
|
||
function legPing(home: Server, jh: Server): number | null {
|
||
/** Для линий на карте достаточно «живого» статуса; `enabled` не скрывает топологию. */
|
||
const hOk = home.status !== "offline"
|
||
const jOk = jh.status !== "offline"
|
||
if (!hOk || !jOk) return null
|
||
if (home.latency == null || jh.latency == null) return null
|
||
return Math.min(995, Math.round(home.latency + jh.latency))
|
||
}
|
||
|
||
/** Рёбра WAN→JumpHost для отрисовки (`latency` с бекенда — те же объекты, что и в каталоге серверов). */
|
||
export function buildWanJhEdges(servers: Server[]): WanJhEdge[] {
|
||
const homes = servers.filter((s) => s.type === "home-router")
|
||
const jhs = servers.filter((s) => s.type === "jump-host" && s.status !== "offline")
|
||
if (homes.length === 0 || jhs.length === 0) return []
|
||
|
||
const edges: WanJhEdge[] = []
|
||
for (const home of homes) {
|
||
const wans = home.wanUplinks?.length ? home.wanUplinks : []
|
||
if (wans.length === 0) continue
|
||
for (let wanIdx = 0; wanIdx < wans.length; wanIdx++) {
|
||
const wan = wans[wanIdx]
|
||
for (const jh of jhs) {
|
||
edges.push({
|
||
homeId: home.id,
|
||
wanIdx,
|
||
jhId: jh.id,
|
||
pingMs: legPing(home, jh),
|
||
dlMbps: Math.max(1, wan.maxDl),
|
||
active: false,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
const key = (e: WanJhEdge) => `${e.homeId}::${e.wanIdx}`
|
||
const score = (p: number | null) => (p == null ? Number.POSITIVE_INFINITY : p)
|
||
const best = new Map<string, WanJhEdge>()
|
||
for (const e of edges) {
|
||
const k = key(e)
|
||
const prev = best.get(k)
|
||
if (!prev || score(e.pingMs) < score(prev.pingMs)) best.set(k, e)
|
||
}
|
||
for (const e of edges) {
|
||
const winner = best.get(key(e))
|
||
e.active =
|
||
winner != null &&
|
||
winner.pingMs != null &&
|
||
e.jhId === winner.jhId &&
|
||
e.pingMs === winner.pingMs
|
||
}
|
||
|
||
return edges
|
||
}
|
||
|
||
/** Ресурсы для карточки узла на карте: при наличии снапшота опроса — как в разделе «Серверы», иначе демо по id. */
|
||
export function buildServerResourceMap(servers: Server[]): Record<string, NetworkMapSrvRes> {
|
||
return Object.fromEntries(
|
||
servers.map((s) => {
|
||
const h = s.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0)
|
||
const ramTotalDemo = s.type === "jump-host" ? 8192 : s.type === "exit-node" ? 4096 : 1024
|
||
const hddTotalDemo = s.type === "home-router" ? 2048 : 16384
|
||
const ramPctDemo = 12 + (h % 72)
|
||
const hddPctDemo = 8 + (h % 75)
|
||
|
||
const hasPollRam =
|
||
s.cpuLoad != null &&
|
||
typeof s.totalMemory === "number" &&
|
||
s.totalMemory > 0 &&
|
||
typeof s.freeMemory === "number"
|
||
|
||
if (hasPollRam) {
|
||
const cpu = Math.max(0, Math.min(100, Math.round(s.cpuLoad!)))
|
||
const ramTotalMb = Math.max(1, Math.round(s.totalMemory! / (1024 * 1024)))
|
||
const usedBytes = Math.max(0, s.totalMemory! - s.freeMemory!)
|
||
const ramUsedMb = Math.min(ramTotalMb, Math.max(0, Math.round(usedBytes / (1024 * 1024))))
|
||
let uptimeSeconds = s.uptime ? parseRouterosUptimeToSeconds(s.uptime) : 0
|
||
if (uptimeSeconds <= 0 && s.uptime) {
|
||
uptimeSeconds =
|
||
(1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60
|
||
}
|
||
if (uptimeSeconds <= 0) {
|
||
uptimeSeconds =
|
||
(1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60
|
||
}
|
||
const hddTotal = hddTotalDemo
|
||
const hddUsed = Math.round((hddTotal * hddPctDemo) / 100)
|
||
return [
|
||
s.id,
|
||
{
|
||
cpu,
|
||
cpuHistory: Array.from({ length: 32 }, (_, i) =>
|
||
Math.max(0, Math.min(100, cpu + Math.round(Math.sin(i * 0.7 + h * 0.1) * 6))),
|
||
),
|
||
ramUsed: ramUsedMb,
|
||
ramTotal: ramTotalMb,
|
||
hddUsed,
|
||
hddTotal,
|
||
uptimeSeconds,
|
||
fromPoll: true,
|
||
} satisfies NetworkMapSrvRes,
|
||
]
|
||
}
|
||
|
||
const cpu = 4 + (h % 68)
|
||
const ramTotal = ramTotalDemo
|
||
const hddTotal = hddTotalDemo
|
||
return [
|
||
s.id,
|
||
{
|
||
cpu,
|
||
cpuHistory: Array.from({ length: 32 }, (_, i) =>
|
||
Math.max(1, Math.min(99, cpu + Math.round(Math.sin(i * 0.7 + h * 0.1) * 15))),
|
||
),
|
||
ramUsed: Math.round((ramTotal * ramPctDemo) / 100),
|
||
ramTotal,
|
||
hddUsed: Math.round((hddTotal * hddPctDemo) / 100),
|
||
hddTotal,
|
||
uptimeSeconds:
|
||
(1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60,
|
||
temp: s.type !== "home-router" ? 34 + (h % 32) : undefined,
|
||
fromPoll: false,
|
||
} satisfies NetworkMapSrvRes,
|
||
]
|
||
}),
|
||
)
|
||
}
|
||
|
||
// ── GRE: сопоставление remote-address с узлами (WAN ≠ API host в БД) ─────────
|
||
|
||
/** Нормализация IP/endpoint из RouterOS (CIDR, zone id). */
|
||
export function normalizeGreEndpoint(addr: string): string {
|
||
return normalizeGreEndpointAddr(addr)
|
||
}
|
||
|
||
function hostWithoutPort(host: string): string {
|
||
const h = host.trim()
|
||
if (h.startsWith("[")) {
|
||
const end = h.indexOf("]")
|
||
return end > 0 ? h.slice(1, end) : h
|
||
}
|
||
const lastColon = h.lastIndexOf(":")
|
||
if (lastColon > 0 && /^\d{1,5}$/.test(h.slice(lastColon + 1)))
|
||
return h.slice(0, lastColon)
|
||
return h
|
||
}
|
||
|
||
function normalizeIpNoMask(raw: string | null | undefined): string {
|
||
if (!raw) return ""
|
||
return normalizeGreEndpoint(raw.split("/")[0] ?? "")
|
||
}
|
||
|
||
function canonicalIpPairKey(aRaw: string, bRaw: string): string | null {
|
||
const a = normalizeGreEndpoint(aRaw)
|
||
const b = normalizeGreEndpoint(bRaw)
|
||
if (!a || !b || a === "0.0.0.0" || b === "0.0.0.0") return null
|
||
const p1 = a <= b ? a : b
|
||
const p2 = a <= b ? b : a
|
||
return `${p1}|${p2}`
|
||
}
|
||
|
||
function canonicalOuterPairKey(
|
||
t: GreTunnel,
|
||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||
): string | null {
|
||
const la = greOuterComparableIps(t.localAddress, resolvedIpv4ByHost)
|
||
const ra = greOuterComparableIps(t.remoteAddress, resolvedIpv4ByHost)
|
||
const left = [...new Set(la.map((x) => normalizeGreEndpoint(x)).filter((x) => x && x !== "0.0.0.0"))]
|
||
const right = [...new Set(ra.map((x) => normalizeGreEndpoint(x)).filter((x) => x && x !== "0.0.0.0"))]
|
||
if (left.length === 0 || right.length === 0) return null
|
||
let best: string | null = null
|
||
for (const l of left) {
|
||
for (const r of right) {
|
||
const p1 = l <= r ? l : r
|
||
const p2 = l <= r ? r : l
|
||
const k = `${p1}|${p2}`
|
||
if (best == null || k < best) best = k
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
/**
|
||
* Найти сервер в каталоге по GRE remote-address: совпадение с management host или WAN IP.
|
||
* Для FQDN в remote-address — передайте карту DNS из `/api/network/resolve-hosts`.
|
||
*
|
||
* Важно: при успешном DNS исходный FQDN всё равно участвует в поиске первым — иначе совпадение
|
||
* только по резолвнутому IP может выбрать «чужой» узел с тем же WAN, а speed-пробы перестанут
|
||
* сходиться с парой концов туннеля.
|
||
*/
|
||
export function findServerByGreRemote(
|
||
servers: Server[],
|
||
remoteAddress: string,
|
||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||
): Server | undefined {
|
||
const fallback = normalizeGreEndpoint(remoteAddress)
|
||
if (!fallback || fallback === "0.0.0.0") return undefined
|
||
|
||
const comparable = greOuterComparableIps(remoteAddress, resolvedIpv4ByHost)
|
||
const tryOrder: string[] = []
|
||
const seen = new Set<string>()
|
||
function pushCandidate(raw: string) {
|
||
const n = normalizeGreEndpoint(raw)
|
||
if (!n || n === "0.0.0.0" || seen.has(n)) return
|
||
seen.add(n)
|
||
tryOrder.push(n)
|
||
}
|
||
|
||
pushCandidate(fallback)
|
||
for (const ip of comparable) pushCandidate(ip)
|
||
|
||
for (const c of tryOrder) {
|
||
for (const s of servers) {
|
||
if (normalizeGreEndpoint(hostWithoutPort(s.host)) === c) return s
|
||
}
|
||
}
|
||
for (const c of tryOrder) {
|
||
for (const s of servers) {
|
||
for (const w of s.wanUplinks ?? []) {
|
||
if (normalizeGreEndpoint(w.ip) === c) return s
|
||
}
|
||
}
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
/** Индекс WAN-аплинка Home Router по внешнему IP (GRE outer / remote-address). */
|
||
export function wanIndexMatchingOuterIp(
|
||
server: Server,
|
||
outerIp: string,
|
||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||
): number | null {
|
||
if (server.type !== "home-router") return null
|
||
if (!normalizeGreEndpoint(outerIp)) return null
|
||
const wans = server.wanUplinks ?? []
|
||
for (let i = 0; i < wans.length; i++) {
|
||
if (wanUplinkIpMatchesGreOuter(wans[i].ip, outerIp, resolvedIpv4ByHost)) return i
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Индекс WAN-спутника на стороне узла, где поднят GRE (совпадает с якорем «source» на карте).
|
||
* Для сопоставления ребра WAN→JH с GRE-бейджем на том же аплинке.
|
||
*/
|
||
export function greSourceWanIndexOnMap(
|
||
home: Server,
|
||
tunnel: GreTunnel,
|
||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||
): number | null {
|
||
if (home.type !== "home-router") return null
|
||
const wans = home.wanUplinks ?? []
|
||
if (wans.length === 0) return null
|
||
const la = tunnel.localAddress?.trim() ?? ""
|
||
let wanIdx: number | null = null
|
||
if (la === "0.0.0.0" || la === "") wanIdx = 0
|
||
else wanIdx = wanIndexMatchingOuterIp(home, tunnel.localAddress, resolvedIpv4ByHost)
|
||
if (wanIdx == null) wanIdx = 0
|
||
return wanIdx
|
||
}
|
||
|
||
function greEndpointAnchor(
|
||
server: Server,
|
||
tunnel: GreTunnel,
|
||
role: "source" | "peer",
|
||
nodePos: Record<string, { x: number; y: number }>,
|
||
wanSatPos: Record<string, { x: number; y: number }[]>,
|
||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||
): { x: number; y: number } {
|
||
const center = (): { x: number; y: number } =>
|
||
nodePos[server.id] ?? { x: W / 2, y: H / 2 }
|
||
|
||
if (server.type !== "home-router") return center()
|
||
|
||
const wans = server.wanUplinks ?? []
|
||
let wanIdx: number | null = null
|
||
if (role === "source") {
|
||
const la = tunnel.localAddress?.trim() ?? ""
|
||
if (la === "0.0.0.0" || la === "") wanIdx = wans.length > 0 ? 0 : null
|
||
else wanIdx = wanIndexMatchingOuterIp(server, tunnel.localAddress, resolvedIpv4ByHost)
|
||
if (wanIdx == null && wans.length > 0) wanIdx = 0
|
||
} else {
|
||
wanIdx = wanIndexMatchingOuterIp(server, tunnel.remoteAddress, resolvedIpv4ByHost)
|
||
}
|
||
|
||
if (wanIdx != null && wanSatPos[server.id]?.[wanIdx])
|
||
return wanSatPos[server.id][wanIdx]!
|
||
return center()
|
||
}
|
||
|
||
/** GRE на карте только между узлами каталога; у HR конец туннеля — у соответствующего WAN-спутника. */
|
||
export interface GreMapEdge {
|
||
tunnel: GreTunnel
|
||
from: { x: number; y: number }
|
||
to: { x: number; y: number }
|
||
fromServer: Server
|
||
toServer: Server
|
||
}
|
||
|
||
export function buildGreMapEdges(
|
||
tunnels: GreTunnel[],
|
||
servers: Server[],
|
||
nodePos: Record<string, { x: number; y: number }>,
|
||
wanSatPos: Record<string, { x: number; y: number }[]>,
|
||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||
): GreMapEdge[] {
|
||
const out: GreMapEdge[] = []
|
||
const seenByLink = new Map<string, number>()
|
||
|
||
const statusRank: Record<GreTunnel["status"], number> = {
|
||
up: 3,
|
||
degraded: 2,
|
||
down: 1,
|
||
}
|
||
|
||
function tunnelPriority(t: GreTunnel): number {
|
||
return (t.enabled ? 100 : 0) + (statusRank[t.status] ?? 0)
|
||
}
|
||
|
||
function canonicalLinkKey(t: GreTunnel, a: Server, b: Server): string {
|
||
const s1 = a.id <= b.id ? a.id : b.id
|
||
const s2 = a.id <= b.id ? b.id : a.id
|
||
// 1) Предпочитаем inner-пару: у directional A→B/B→A она одинакова по /30.
|
||
const inner = canonicalIpPairKey(
|
||
normalizeIpNoMask(t.localInnerIp),
|
||
normalizeIpNoMask(t.remoteInnerIp),
|
||
)
|
||
if (inner) return `${s1}|${s2}|inner:${inner}`
|
||
|
||
// 2) Иначе outer-пара с учетом DNS/FQDN→IPv4 сопоставления.
|
||
const outer = canonicalOuterPairKey(t, resolvedIpv4ByHost)
|
||
if (outer) return `${s1}|${s2}|outer:${outer}`
|
||
|
||
// 3) Крайний случай: чтобы не схлопнуть потенциально разные линki.
|
||
return `${s1}|${s2}|id:${t.id}`
|
||
}
|
||
|
||
for (const t of tunnels) {
|
||
const fromServer = servers.find((s) => s.id === t.serverId)
|
||
const toServer = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
|
||
if (!fromServer || !toServer) continue
|
||
if (fromServer.id === toServer.id) continue
|
||
|
||
const from = greEndpointAnchor(fromServer, t, "source", nodePos, wanSatPos, resolvedIpv4ByHost)
|
||
const to = greEndpointAnchor(toServer, t, "peer", nodePos, wanSatPos, resolvedIpv4ByHost)
|
||
const edge: GreMapEdge = { tunnel: t, from, to, fromServer, toServer }
|
||
const key = canonicalLinkKey(t, fromServer, toServer)
|
||
const prevIdx = seenByLink.get(key)
|
||
if (prevIdx == null) {
|
||
seenByLink.set(key, out.length)
|
||
out.push(edge)
|
||
continue
|
||
}
|
||
const prev = out[prevIdx]
|
||
if (tunnelPriority(t) > tunnelPriority(prev.tunnel)) {
|
||
out[prevIdx] = edge
|
||
}
|
||
}
|
||
return out
|
||
}
|