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:
+172
-11
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user