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.
806 lines
28 KiB
TypeScript
806 lines
28 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
|
||
|
||
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: правая (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 }
|
||
}
|
||
|
||
/**
|
||
* Суммарная задержка «дом → 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
|
||
}
|