chore: synchronize pending app/backend updates and repository hygiene

Includes current frontend and backend work in progress and removes generated artifacts from tracking to keep the repository clean for дальнейшая разработка.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-05-07 12:29:04 +07:00
co-authored by Cursor
parent bdb9b72fac
commit 5f31bb47fb
81 changed files with 11976 additions and 1239 deletions
+172 -11
View File
@@ -46,7 +46,7 @@ export const NETWORK_MAP_PIPELINE_Y = 300
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
*/
export const NETWORK_MAP_LAYOUT_REVISION = 3
export const NETWORK_MAP_LAYOUT_REVISION = 5
export interface WanJhEdge {
homeId: string
@@ -106,6 +106,41 @@ function spreadInSegment(count: number, index: number, xMin: number, xMax: numbe
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. */
export function computeNetworkMapLayout(servers: Server[]): {
nodePos: Record<string, { x: number; y: number }>
@@ -119,21 +154,70 @@ export function computeNetworkMapLayout(servers: Server[]): {
const laneW = (span - 2 * laneGap) / 3
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)
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, i) => {
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: spreadInSegment(group.length, i, xMin, xMax),
y: NETWORK_MAP_PIPELINE_Y,
// Жёсткие колонки: Gateway слева, JH по центру, EN справа.
x: laneCenterX,
y: clamp(baseline + typeYOffset[type] + spread, minNodeY, maxNodeY),
}
})
xLane += laneW + laneGap
}
const hr = servers.filter((s) => s.type === "home-router")
const homes = laneByType.get("home-router") ?? []
const jhs = laneByType.get("jump-host") ?? []
const exits = laneByType.get("exit-node") ?? []
resolveLaneCollisions(homes.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
resolveLaneCollisions(jhs.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
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
@@ -142,7 +226,7 @@ export function computeNetworkMapLayout(servers: Server[]): {
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 rowY = base.y
const maxV = H - 2 * MARGIN - 100
const vGap =
n <= 1 ? 0 : Math.min(56, maxV / Math.max(1, n - 1))
@@ -163,8 +247,9 @@ export function computeNetworkMapLayout(servers: Server[]): {
* Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов.
*/
function legPing(home: Server, jh: Server): number | null {
const hOk = home.enabled && home.status !== "offline"
const jOk = jh.enabled && jh.status !== "offline"
/** Для линий на карте достаточно «живого» статуса; `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))
@@ -172,8 +257,8 @@ function legPing(home: Server, jh: Server): number | null {
/** Рёбра 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")
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[] = []
@@ -307,6 +392,41 @@ function hostWithoutPort(host: string): string {
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`.
@@ -432,6 +552,36 @@ export function buildGreMapEdges(
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 fromServer = servers.find((s) => s.id === t.serverId)
const toServer = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
@@ -440,7 +590,18 @@ export function buildGreMapEdges(
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 })
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
}