feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

+904
View File
@@ -0,0 +1,904 @@
/**
* Раскладка узлов карты сети и синтетические WAN→JH рёбра по данным серверов (как в оптимизаторе).
*/
import type { GreTunnel } from "@/lib/data"
import type { Server, ServerType } from "@/lib/data"
import {
greOuterComparableIps,
normalizeGreEndpointAddr,
wanUplinkIpMatchesGreOuter,
} from "@/lib/gre-endpoint-resolve"
/** Неизвестные live метрики GRE; синтетические значения доступны только при demo=true. */
export interface TunnelProbe {
pingMs: number | null
dlMbps: number | null
ulMbps: number | null
}
export function greTunnelProbe(t: GreTunnel, demo = false): TunnelProbe {
if (!demo) return {pingMs:null,dlMbps:null,ulMbps:null}
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 = 1240
const H = 580
const MARGIN = 72
const SERVICE_COL_W = 150
export { SERVICE_COL_W }
/** Карточка конечного сервиса на карте (центр = позиция узла). */
export const MAP_SERVICE_NODE_W = 86
export const MAP_SERVICE_NODE_H = 58
/** Одна горизонтальная «полка» на карте: 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_W = W
export const NETWORK_MAP_H = H
export const NETWORK_MAP_LAYOUT_REVISION = 7
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 | null
cpuHistory: number[]
ramUsed: number | null
ramTotal: number | null
hddUsed: number | null
hddTotal: number | null
uptimeSeconds: number | null
temp?: number
/** Live значения сняты с последнего опроса; отсутствующие поля и HDD неизвестны. */
measuredAt?: string | null
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 - SERVICE_COL_W
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<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 }
}
/** Колонка конечных сервисов справа от EN. */
export function placeServiceNodes(
serviceIds: string[],
enPositions: Array<{ x: number; y: number }>,
): Record<string, { x: number; y: number }> {
const out: Record<string, { x: number; y: number }> = {}
if (serviceIds.length === 0) return out
const minY = MARGIN + 70
const maxY = H - 72
const x = W - MARGIN - SERVICE_COL_W / 2
const enYs = enPositions.map((p) => p.y).filter((y) => Number.isFinite(y))
const centerY = enYs.length ? enYs.reduce((a, b) => a + b, 0) / enYs.length : (minY + maxY) / 2
const n = serviceIds.length
const gap = Math.min(96, (maxY - minY) / Math.max(1, n))
const span = gap * (n - 1)
const start = clamp(centerY - span / 2, minY, maxY - span)
serviceIds.forEach((id, i) => {
out[id] = { x, y: n === 1 ? clamp(centerY, minY, maxY) : start + i * gap }
})
return out
}
/** Столбец сервисов раскрытой страны: тот же правый x, по вертикали вокруг Y страны. */
export function placeCountryServiceNodes(
serviceIds: string[],
parentPos: { x: number; y: number } | undefined,
): Record<string, { x: number; y: number }> {
const out: Record<string, { x: number; y: number }> = {}
if (serviceIds.length === 0 || !parentPos) return out
const minY = MARGIN + 70
const maxY = H - 72
const x = W - MARGIN - SERVICE_COL_W / 2
const n = serviceIds.length
const gap = Math.min(80, (maxY - minY) / Math.max(1, n))
const span = gap * (n - 1)
const start = clamp(parentPos.y - span / 2, minY, maxY - span)
serviceIds.forEach((id, i) => {
out[id] = { x, y: n === 1 ? clamp(parentPos.y, minY, maxY) : start + i * gap }
})
return out
}
/**
* Суммарная задержка «дом → 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
}
/** Ресурсы API без синтетической истории и fallback; demo=true включает демонстрацию по id. */
export function buildServerResourceMap(servers: Server[], demo = false): Record<string, NetworkMapSrvRes> {
if (!demo) return Object.fromEntries(servers.map(s=>[s.id,{
cpu:s.cpuLoad ?? null,cpuHistory:[],
ramTotal:s.totalMemory==null?null:s.totalMemory/(1024*1024),
ramUsed:s.totalMemory==null||s.freeMemory==null?null:(s.totalMemory-s.freeMemory)/(1024*1024),
hddUsed:null,hddTotal:null,
uptimeSeconds:s.uptime && /^(?:\d+[wdhms])+$/.test(s.uptime)?parseRouterosUptimeToSeconds(s.uptime):null,
measuredAt:s.polledAt ?? null,fromPoll:true,
} satisfies 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 a = servers.find((s) => s.id === t.serverId)
const 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
}
/**
* Обрезать отрезок центр круга → центр прямоугольника по ободу круга и AABB карточки.
* Пунктир EN→сервис визуально упирается в край, как GRE под кругами узлов.
*/
export function clipSegmentCircleToRect(
x1: number,
y1: number,
r: number,
x2: number,
y2: number,
hw: number,
hh: number,
pad = 1.5,
): { x1: number; y1: number; x2: number; y2: number } {
const dx = x2 - x1
const dy = y2 - y1
const len = Math.hypot(dx, dy)
if (len < 1e-6) return { x1, y1, x2, y2 }
const ux = dx / len
const uy = dy / len
const sx = x1 + ux * (r + pad)
const sy = y1 + uy * (r + pad)
const absDx = Math.abs(dx)
const absDy = Math.abs(dy)
const u = Math.min(
absDx < 1e-9 ? 1 : (hw + pad) / absDx,
absDy < 1e-9 ? 1 : (hh + pad) / absDy,
)
const uu = Math.min(Math.max(u, 0), 0.48)
const ex = x2 - dx * uu
const ey = y2 - dy * uu
if ((ex - sx) * dx + (ey - sy) * dy <= 0) {
const mx = (x1 + x2) / 2
const my = (y1 + y2) / 2
return { x1: mx - ux * 2, y1: my - uy * 2, x2: mx + ux * 2, y2: my + uy * 2 }
}
return { x1: sx, y1: sy, x2: ex, y2: ey }
}