/** * Раскладка узлов карты сети и синтетические 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 = 3 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) } /** Детерминированные координаты узлов и «спутников» WAN под home-router. */ export function computeNetworkMapLayout(servers: Server[]): { 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 const laneOrder: ServerType[] = ["home-router", "jump-host", "exit-node"] let xLane = MARGIN for (const type of laneOrder) { const group = servers.filter((s) => s.type === type) const xMin = xLane const xMax = xLane + laneW group.forEach((s, i) => { nodePos[s.id] = { x: spreadInSegment(group.length, i, xMin, xMax), y: NETWORK_MAP_PIPELINE_Y, } }) xLane += laneW + laneGap } const hr = servers.filter((s) => s.type === "home-router") /** Центр колонки 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 = NETWORK_MAP_PIPELINE_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 { const hOk = home.enabled && home.status !== "offline" const jOk = jh.enabled && 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" && s.enabled) const jhs = servers.filter((s) => s.type === "jump-host" && s.enabled && 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 } /** * Найти сервер в каталоге по 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[] = [] 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) out.push({ tunnel: t, from, to, fromServer, toServer }) } return out }