diff --git a/frontend/src/NetworkMapUnifi.jsx b/frontend/src/NetworkMapUnifi.jsx index f8e5e09..0b4b1f4 100644 --- a/frontend/src/NetworkMapUnifi.jsx +++ b/frontend/src/NetworkMapUnifi.jsx @@ -9,23 +9,47 @@ const UNIFI_TEXT = '#f1f5f9'; const UNIFI_TEXT_MUTED = '#94a3b8'; const NODE_WIDTH = 88; const NODE_HEIGHT = 52; -const CIRCLE_RADIUS = 240; const STORAGE_KEY = 'network-map-unifi-positions'; -function computeCircleLayout(servers, size) { - const n = servers.length; - if (n === 0) return {}; - const cx = size.w / 2; - const cy = size.h / 2; - const r = Math.min(CIRCLE_RADIUS, Math.min(size.w, size.h) * 0.4); +function computeInitialLayout(servers, size) { const positions = {}; - servers.forEach((s, i) => { - const angle = (2 * Math.PI * i) / n - Math.PI / 2; - positions[String(s.ip)] = { - x: cx + r * Math.cos(angle), - y: cy + r * Math.sin(angle), - }; + if (!servers.length) return positions; + + // Группируем: сначала home, затем jumphost, затем exit, затем остальные + const homes = servers.filter((s) => String(s.type || '').toLowerCase() === 'home'); + const jumphosts = servers.filter((s) => String(s.type || '').toLowerCase() === 'jumphost'); + const exits = servers.filter((s) => String(s.type || '').toLowerCase() === 'exit'); + const others = servers.filter((s) => { + const t = String(s.type || '').toLowerCase(); + return t !== 'home' && t !== 'jumphost' && t !== 'exit'; }); + + const columns = [homes, jumphosts, exits, others].filter((col) => col.length > 0); + const colCount = columns.length || 1; + const paddingX = 80; + const usableWidth = Math.max(200, size.w - paddingX * 2); + const colStep = usableWidth / Math.max(1, colCount - 1); + const baseX = paddingX; + + columns.forEach((col, colIdx) => { + // В пределах колонки сортируем по dns/ip для стабильности + const sorted = [...col].sort((a, b) => { + const la = (a.dns || a.ip || '').toString(); + const lb = (b.dns || b.ip || '').toString(); + return la.localeCompare(lb); + }); + const n = sorted.length; + const x = baseX + colStep * colIdx; + const topPadding = 60; + const bottomPadding = 60; + const usableHeight = Math.max(120, size.h - topPadding - bottomPadding); + sorted.forEach((s, i) => { + const t = n > 1 ? i / (n - 1) : 0.5; + const y = topPadding + usableHeight * t; + positions[String(s.ip)] = { x, y }; + }); + }); + return positions; } @@ -62,14 +86,14 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa const raw = window.localStorage.getItem(STORAGE_KEY); if (raw) saved = JSON.parse(raw) || {}; } catch {} - const circle = computeCircleLayout(servers, size); + const initial = computeInitialLayout(servers, size); const merged = {}; servers.forEach((s, i) => { const ip = String(s.ip); if (saved[ip] && typeof saved[ip].x === 'number' && typeof saved[ip].y === 'number') { merged[ip] = { x: saved[ip].x, y: saved[ip].y }; } else { - merged[ip] = circle[ip] ?? { x: size.w / 2, y: size.h / 2 }; + merged[ip] = initial[ip] ?? { x: size.w / 2, y: size.h / 2 }; } }); setPositions(merged); @@ -117,10 +141,10 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa }, [drag, getSvgCoords]); const resetLayout = useCallback(() => { - const circle = computeCircleLayout(servers, size); - setPositions(circle); + const initial = computeInitialLayout(servers, size); + setPositions(initial); try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(circle)); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initial)); } catch {} }, [servers, size]); @@ -158,36 +182,60 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa }).filter((e) => e.p1 && e.p2); }, [connections, positions, pingMap]); - const pathD = useCallback((e) => { + // Вычисляем старт/финиш и контрольную точку так, чтобы рёбра выходили + // с «боков» карточек и красиво изгибались (как в UniFi). + const computeCurvePoints = useCallback((e) => { const { p1, p2 } = e; - const mx = (p1.x + p2.x) / 2; - const my = (p1.y + p2.y) / 2; const dx = p2.x - p1.x; const dy = p2.y - p1.y; - const len = Math.hypot(dx, dy) || 1; - const perpX = (-dy / len) * 35; - const perpY = (dx / len) * 35; + const adx = Math.abs(dx); + const ady = Math.abs(dy); + + let sx = p1.x; + let sy = p1.y; + let ex = p2.x; + let ey = p2.y; + + if (adx >= ady) { + const sign = dx >= 0 ? 1 : -1; + sx = p1.x + sign * (NODE_WIDTH / 2); + sy = p1.y; + ex = p2.x - sign * (NODE_WIDTH / 2); + ey = p2.y; + } else { + const sign = dy >= 0 ? 1 : -1; + sx = p1.x; + sy = p1.y + sign * (NODE_HEIGHT / 2); + ex = p2.x; + ey = p2.y - sign * (NODE_HEIGHT / 2); + } + + const mx = (sx + ex) / 2; + const my = (sy + ey) / 2; + const ddx = ex - sx; + const ddy = ey - sy; + const len = Math.hypot(ddx, ddy) || 1; + const baseOffset = 40; + const perpX = (-ddy / len) * baseOffset; + const perpY = (ddx / len) * baseOffset; const cpx = mx + perpX; const cpy = my + perpY; - return `M ${p1.x} ${p1.y} Q ${cpx} ${cpy} ${p2.x} ${p2.y}`; + + return { sx, sy, ex, ey, cpx, cpy }; }, []); + const pathD = useCallback((e) => { + const { sx, sy, ex, ey, cpx, cpy } = computeCurvePoints(e); + return `M ${sx} ${sy} Q ${cpx} ${cpy} ${ex} ${ey}`; + }, [computeCurvePoints]); + const labelPoint = useCallback((e) => { - const { p1, p2 } = e; - const mx = (p1.x + p2.x) / 2; - const my = (p1.y + p2.y) / 2; - const dx = p2.x - p1.x; - const dy = p2.y - p1.y; - const len = Math.hypot(dx, dy) || 1; - const perpX = (-dy / len) * 35; - const perpY = (dx / len) * 35; - const cpx = mx + perpX; - const cpy = my + perpY; + const { sx, sy, ex, ey, cpx, cpy } = computeCurvePoints(e); const t = 0.5; - const lx = (1 - t) * (1 - t) * p1.x + 2 * (1 - t) * t * cpx + t * t * p2.x; - const ly = (1 - t) * (1 - t) * p1.y + 2 * (1 - t) * t * cpy + t * t * p2.y; + const lx = (1 - t) * (1 - t) * sx + 2 * (1 - t) * t * cpx + t * t * ex; + const ly = (1 - t) * (1 - t) * sy + 2 * (1 - t) * t * cpy + t * t * ey; return { x: lx, y: ly }; - }, []); + }, [computeCurvePoints]); if (servers.length === 0) return null;