/** * Раскладка узлов карты сети и синтетические 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 type NodeRole = "HR" | "JH" | "EN" function roleOfServer(s: Server): NodeRole | null { if (s.type === "home-router") return "HR" if (s.type === "jump-host") return "JH" if (s.type === "exit-node") return "EN" return null } function roleLayer(role: NodeRole): number { switch (role) { case "HR": return 1 case "JH": return 2 case "EN": return 3 } } function layerOfServer(s: Server): number | null { const r = roleOfServer(s) return r ? roleLayer(r) : null } /** * Увеличивать при изменении алгоритма раскладки спутников/узлов. * Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload). */ export const NETWORK_MAP_LAYOUT_REVISION = 6 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, 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. * * Важно: раскладка строго иерархическая по ролям: * - HR (home-router) слева, * - JH (jump-host) по центру, * - EN (exit-node) справа. * * Это не force-layout: координаты полностью детерминированы по составу servers. */ function average(nums: number[]): number | null { if (nums.length === 0) return null return nums.reduce((acc, n) => acc + n, 0) / nums.length } function alignLayerCenterY( ids: string[], nodePos: Record, targetCenterY: number, minY: number, maxY: number, ): void { if (ids.length === 0) return const ys = ids .map((id) => nodePos[id]?.y) .filter((v): v is number => typeof v === "number") const currentCenter = average(ys) if (currentCenter == null) return const delta = targetCenterY - currentCenter for (const id of ids) { const p = nodePos[id] if (!p) continue p.y = clamp(p.y + delta, minY, maxY) } } type HierarchyLinks = { hrToJh: Map> jhToEn: Map> hrToEn: Map> } function buildHierarchyLinks( servers: Server[], tunnels: GreTunnel[], resolvedIpv4ByHost?: ReadonlyMap, ): HierarchyLinks { const hrToJh = new Map>() const jhToEn = new Map>() const hrToEn = new Map>() const byId = new Map(servers.map((s) => [s.id, s] as const)) function add(map: Map>, from: string, to: string) { const set = map.get(from) ?? new Set() set.add(to) map.set(from, set) } for (const t of tunnels) { const a = byId.get(String(t.serverId)) const b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost) if (!a || !b || a.id === b.id) continue const la = layerOfServer(a) const lb = layerOfServer(b) if (la == null || lb == null || la === lb) continue const from = la < lb ? a : b const to = la < lb ? b : a const fr = roleOfServer(from) const tr = roleOfServer(to) if (!fr || !tr) continue if (fr === "HR" && tr === "JH") add(hrToJh, from.id, to.id) else if (fr === "JH" && tr === "EN") add(jhToEn, from.id, to.id) else if (fr === "HR" && tr === "EN") add(hrToEn, from.id, to.id) } return { hrToJh, jhToEn, hrToEn } } export function computeNetworkMapLayout( servers: Server[], tunnels: GreTunnel[] = [], resolvedIpv4ByHost?: ReadonlyMap, ): { nodePos: Record wanSatPos: Record } { const nodePos: Record = {} const wanSatPos: Record = {} const span = W - 2 * MARGIN const laneGap = Math.min(44, span * 0.04) const laneW = (span - 2 * laneGap) / 3 // Жёсткое позиционирование по иерархическим слоям: // - HR: левая полоса (0–20%), // - JH: центральная (40–60%), // - EN: правая (80–100%). const laneOrder: ServerType[] = ["home-router", "jump-host", "exit-node"] const laneByType = new Map() const minNodeY = MARGIN + 70 const maxNodeY = H - 72 const laneMinGap = 110 const typeYOffset: Record = { "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() 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) => { 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] = { 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") ?? [] const links = buildHierarchyLinks(servers, tunnels, resolvedIpv4ByHost) const hrParentsByJh = new Map() for (const [hrId, jhSet] of links.hrToJh.entries()) { for (const jhId of jhSet) { const arr = hrParentsByJh.get(jhId) ?? [] arr.push(hrId) hrParentsByJh.set(jhId, arr) } } // JH тянем по Y к HR-родителям, чтобы хабы не "плавали" от site-сортировки. for (const jh of jhs) { const parentIds = hrParentsByJh.get(jh.id) ?? [] const parentYs = parentIds .map((id) => nodePos[id]?.y) .filter((v): v is number => typeof v === "number") const target = average(parentYs) if (target != null && nodePos[jh.id]) { nodePos[jh.id]!.y = clamp(target, minNodeY, maxNodeY) } } resolveLaneCollisions(homes.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap) resolveLaneCollisions(jhs.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap) const jhParentsByEn = new Map() for (const [jhId, enSet] of links.jhToEn.entries()) { for (const enId of enSet) { const arr = jhParentsByEn.get(enId) ?? [] arr.push(jhId) jhParentsByEn.set(enId, arr) } } const hrParentsByEn = new Map() for (const [hrId, enSet] of links.hrToEn.entries()) { for (const enId of enSet) { const arr = hrParentsByEn.get(enId) ?? [] arr.push(hrId) hrParentsByEn.set(enId, arr) } } // EN вешаем на JH-родителей (или HR fallback), чтобы получать читаемую правую "leaf"-ветку. for (const en of exits) { const jhParentIds = jhParentsByEn.get(en.id) ?? [] const jhParentYs = jhParentIds .map((id) => nodePos[id]?.y) .filter((v): v is number => typeof v === "number") const jhTarget = average(jhParentYs) if (jhTarget != null && nodePos[en.id]) { nodePos[en.id]!.y = clamp(jhTarget, minNodeY, maxNodeY) continue } const hrParentIds = hrParentsByEn.get(en.id) ?? [] const hrParentYs = hrParentIds .map((id) => nodePos[id]?.y) .filter((v): v is number => typeof v === "number") const hrTarget = average(hrParentYs) if (hrTarget != null && nodePos[en.id]) { nodePos[en.id]!.y = clamp(hrTarget, minNodeY, maxNodeY) } } resolveLaneCollisions(exits.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap) // EN-слой выравниваем относительно центра HR-слоя: // карта читается как "ingress слева -> leaf справа" на одной оси. const hrCenterY = average( homes .map((s) => nodePos[s.id]?.y) .filter((v): v is number => typeof v === "number"), ) if (hrCenterY != null) { alignLayerCenterY(exits.map((s) => s.id), nodePos, hrCenterY, minNodeY, maxNodeY) 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 - 96 // Разносим WAN-аплинки по высоте заметнее для читаемости подписей/бейджей. const preferredGap = 140 const vGap = n <= 1 ? 0 : Math.min(preferredGap, 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() 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 { 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 | 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, ): 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() 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, ): 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, ): 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, wanSatPos: Record, resolvedIpv4ByHost?: ReadonlyMap, ): { 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, wanSatPos: Record, resolvedIpv4ByHost?: ReadonlyMap, ): GreMapEdge[] { const out: GreMapEdge[] = [] const seenByLink = new Map() const statusRank: Record = { 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) { let a = servers.find((s) => s.id === t.serverId) let b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost) if (!a || !b) continue if (a.id === b.id) continue const la = layerOfServer(a) const lb = layerOfServer(b) if (la == null || lb == null) continue // EN→EN, JH→JH и т.п. на карте не показываем — только межуровневые рёбра. if (la === lb) continue // Строгий поток слева направо: HR → JH → EN. // Если направление туннеля против иерархии — переворачиваем визуальное ребро. let fromServer = a let toServer = b if (la > lb) { fromServer = b toServer = a } 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 }