Files
MikrotikManager/lib/network-map-layout.ts
T
Denozordec 84ecd4f061 feat: enhance backups page with live data loading and backup job management
Implemented live data fetching for servers and backups on the backups page, replacing static initial data. Added functionality for manual backup creation and job status tracking, including error handling and UI updates. Updated the network map layout to improve node prioritization and visual representation of server roles.

Also, registered new backups API routes in the backend for improved data handling.
2026-05-07 14:24:58 +07:00

806 lines
28 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.
/**
* Раскладка узлов карты сети и синтетические 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<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.
*
* Важно: раскладка строго иерархическая по ролям:
* - 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<string, { x: number; y: number }>,
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<string, Set<string>>
jhToEn: Map<string, Set<string>>
hrToEn: Map<string, Set<string>>
}
function buildHierarchyLinks(
servers: Server[],
tunnels: GreTunnel[],
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): HierarchyLinks {
const hrToJh = new Map<string, Set<string>>()
const jhToEn = new Map<string, Set<string>>()
const hrToEn = new Map<string, Set<string>>()
const byId = new Map(servers.map((s) => [s.id, s] as const))
function add(map: Map<string, Set<string>>, from: string, to: string) {
const set = map.get(from) ?? new Set<string>()
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<string, string>,
): {
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
// Жёсткое позиционирование по иерархическим слоям:
// - HR: левая полоса (0–20%),
// - JH: центральная (40–60%),
// - EN: правая (80100%).
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) => {
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<string, string[]>()
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<string, string[]>()
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<string, string[]>()
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<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) {
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
}