feat(NetworkMapDashboard, NetworkMapUnifi): enhance network visualization with tunnel interface connections and draggable node layout functionality
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s
This commit is contained in:
@@ -34,16 +34,37 @@ export default function NetworkMapDashboard() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const pingAbortRef = useRef(false);
|
const pingAbortRef = useRef(false);
|
||||||
|
|
||||||
|
/** Связи строятся из интерфейсов раздела «Сетевые настройки» (/network-config), tunnelInterfaces */
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [serversRes, connRes] = await Promise.all([
|
const [serversRes, configRes] = await Promise.all([
|
||||||
api.get('/servers'),
|
api.get('/servers'),
|
||||||
api.get('/server-connections'),
|
api.get('/network-config').catch(() => ({ data: null })),
|
||||||
]);
|
]);
|
||||||
const serversList = Array.isArray(serversRes?.data) ? serversRes.data : [];
|
const serversList = Array.isArray(serversRes?.data) ? serversRes.data : [];
|
||||||
const connList = Array.isArray(connRes?.data) ? connRes.data : [];
|
const config = configRes?.data || {};
|
||||||
|
const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : [];
|
||||||
|
|
||||||
|
const getServer = (serverId) =>
|
||||||
|
serversList.find((s) => s.id === serverId || s.ip === serverId || s.dns === serverId);
|
||||||
|
|
||||||
|
const connList = [];
|
||||||
|
tunnelInterfaces.forEach((iface) => {
|
||||||
|
if (!iface.serverId || !iface.serverId2) return;
|
||||||
|
const s1 = getServer(iface.serverId);
|
||||||
|
const s2 = getServer(iface.serverId2);
|
||||||
|
if (!s1 || !s2 || s1.ip === s2.ip) return;
|
||||||
|
connList.push({
|
||||||
|
from: s1.ip,
|
||||||
|
to: s2.ip,
|
||||||
|
tunnelType: iface.type || 'GRE',
|
||||||
|
ipA: iface.localIp,
|
||||||
|
ipB: iface.remoteIp,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
setServers(serversList);
|
setServers(serversList);
|
||||||
setConnections(connList);
|
setConnections(connList);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -156,7 +177,7 @@ export default function NetworkMapDashboard() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Карта сети"
|
title="Карта сети"
|
||||||
icon={<IconTopologyRing size={24} />}
|
icon={<IconTopologyRing size={24} />}
|
||||||
meta="Серверы и пинг между ними (на рёбрах — задержка в мс)"
|
meta="Связи из раздела «Сетевые настройки» → Интерфейсы; на рёбрах — пинг (мс)"
|
||||||
actions={
|
actions={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -171,7 +192,7 @@ export default function NetworkMapDashboard() {
|
|||||||
/>
|
/>
|
||||||
{servers.length === 0 ? (
|
{servers.length === 0 ? (
|
||||||
<div className="alert alert-info">
|
<div className="alert alert-info">
|
||||||
Нет серверов. Добавьте серверы и связи в разделе «Серверы».
|
Нет серверов. Добавьте серверы в разделе «Серверы».
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<NetworkMapUnifi
|
<NetworkMapUnifi
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useCallback, useState, useRef, useEffect } from 'react';
|
import { useMemo, useCallback, useState, useRef, useEffect } from 'react';
|
||||||
|
import { IconRefresh } from '@tabler/icons-react';
|
||||||
|
|
||||||
const UNIFI_BG = '#0f172a';
|
const UNIFI_BG = '#0f172a';
|
||||||
const UNIFI_CARD_BG = '#1e293b';
|
const UNIFI_CARD_BG = '#1e293b';
|
||||||
@@ -9,16 +10,35 @@ const UNIFI_TEXT_MUTED = '#94a3b8';
|
|||||||
const NODE_WIDTH = 88;
|
const NODE_WIDTH = 88;
|
||||||
const NODE_HEIGHT = 52;
|
const NODE_HEIGHT = 52;
|
||||||
const CIRCLE_RADIUS = 240;
|
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);
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Карта сети в стиле UNIFI: тёмный фон, простые иконки узлов, зелёные линии, пинг на рёбрах.
|
* Карта сети в стиле UNIFI: тёмный фон, простые иконки узлов, зелёные линии, пинг на рёбрах.
|
||||||
* @param {Array} servers - список серверов { ip, dns, ... }
|
* Узлы можно перетаскивать; раскладка сохраняется в localStorage. Круг — только начальная раскладка.
|
||||||
* @param {Array} connections - связи { from, to, tunnelType? }
|
|
||||||
* @param {Object} pingMap - ключ "fromIp:toIp" -> число (мс)
|
|
||||||
*/
|
*/
|
||||||
export default function NetworkMapUnifi({ servers = [], connections = [], pingMap = {} }) {
|
export default function NetworkMapUnifi({ servers = [], connections = [], pingMap = {} }) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
const svgRef = useRef(null);
|
||||||
const [size, setSize] = useState({ w: 800, h: 520 });
|
const [size, setSize] = useState({ w: 800, h: 520 });
|
||||||
|
const [positions, setPositions] = useState({});
|
||||||
|
const [drag, setDrag] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
@@ -31,23 +51,90 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { positions } = useMemo(() => {
|
// Инициализация позиций: из localStorage или по кругу
|
||||||
const n = servers.length;
|
useEffect(() => {
|
||||||
if (n === 0) return { positions: {} };
|
if (servers.length === 0) {
|
||||||
const cx = size.w / 2;
|
setPositions({});
|
||||||
const cy = size.h / 2;
|
return;
|
||||||
const r = Math.min(CIRCLE_RADIUS, (Math.min(size.w, size.h) * 0.4));
|
}
|
||||||
const positions = {};
|
let saved = {};
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) saved = JSON.parse(raw) || {};
|
||||||
|
} catch {}
|
||||||
|
const circle = computeCircleLayout(servers, size);
|
||||||
|
const merged = {};
|
||||||
servers.forEach((s, i) => {
|
servers.forEach((s, i) => {
|
||||||
const angle = (2 * Math.PI * i) / n - Math.PI / 2;
|
const ip = String(s.ip);
|
||||||
positions[String(s.ip)] = {
|
if (saved[ip] && typeof saved[ip].x === 'number' && typeof saved[ip].y === 'number') {
|
||||||
x: cx + r * Math.cos(angle),
|
merged[ip] = { x: saved[ip].x, y: saved[ip].y };
|
||||||
y: cy + r * Math.sin(angle),
|
} else {
|
||||||
};
|
merged[ip] = circle[ip] ?? { x: size.w / 2, y: size.h / 2 };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return { positions };
|
setPositions(merged);
|
||||||
|
}, [servers.length, size.w, size.h]);
|
||||||
|
|
||||||
|
const savePositions = useCallback((next) => {
|
||||||
|
setPositions(next);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getSvgCoords = useCallback((clientX, clientY) => {
|
||||||
|
const svg = svgRef.current;
|
||||||
|
if (!svg) return null;
|
||||||
|
const pt = svg.createSVGPoint();
|
||||||
|
pt.x = clientX;
|
||||||
|
pt.y = clientY;
|
||||||
|
const p = pt.matrixTransform(svg.getScreenCTM().inverse());
|
||||||
|
return { x: p.x, y: p.y };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!drag) return;
|
||||||
|
const onMove = (e) => {
|
||||||
|
const p = getSvgCoords(e.clientX, e.clientY);
|
||||||
|
if (!p) return;
|
||||||
|
setPositions((prev) => {
|
||||||
|
const pos = prev[drag.nodeId];
|
||||||
|
if (!pos) return prev;
|
||||||
|
const next = { ...prev, [drag.nodeId]: { x: drag.startX + (p.x - drag.mouseX), y: drag.startY + (p.y - drag.mouseY) } };
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
|
} catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onUp = () => setDrag(null);
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
}, [drag, getSvgCoords]);
|
||||||
|
|
||||||
|
const resetLayout = useCallback(() => {
|
||||||
|
const circle = computeCircleLayout(servers, size);
|
||||||
|
setPositions(circle);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(circle));
|
||||||
|
} catch {}
|
||||||
}, [servers, size]);
|
}, [servers, size]);
|
||||||
|
|
||||||
|
const onNodeMouseDown = useCallback(
|
||||||
|
(e, ip) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const p = getSvgCoords(e.clientX, e.clientY);
|
||||||
|
const pos = positions[ip];
|
||||||
|
if (!p || !pos) return;
|
||||||
|
setDrag({ nodeId: ip, startX: pos.x, startY: pos.y, mouseX: p.x, mouseY: p.y });
|
||||||
|
},
|
||||||
|
[getSvgCoords, positions]
|
||||||
|
);
|
||||||
|
|
||||||
const edgesWithPing = useMemo(() => {
|
const edgesWithPing = useMemo(() => {
|
||||||
return (connections || []).map((c) => {
|
return (connections || []).map((c) => {
|
||||||
const from = String(c.from);
|
const from = String(c.from);
|
||||||
@@ -116,9 +203,11 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
viewBox={`0 0 ${size.w} ${size.h}`}
|
viewBox={`0 0 ${size.w} ${size.h}`}
|
||||||
@@ -162,15 +251,18 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
{/* Узлы: карточки серверов в стиле UNIFI */}
|
{/* Узлы: карточки серверов в стиле UNIFI (перетаскиваемые) */}
|
||||||
{servers.map((s) => {
|
{servers.map((s) => {
|
||||||
const pos = positions[String(s.ip)];
|
const pos = positions[String(s.ip)];
|
||||||
if (!pos) return null;
|
if (!pos) return null;
|
||||||
const label = s.dns?.split('.')[0] || s.ip || '?';
|
const label = s.dns?.split('.')[0] || s.ip || '?';
|
||||||
|
const isDragging = drag?.nodeId === s.ip;
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
key={s.ip}
|
key={s.ip}
|
||||||
transform={`translate(${pos.x - NODE_WIDTH / 2}, ${pos.y - NODE_HEIGHT / 2})`}
|
transform={`translate(${pos.x - NODE_WIDTH / 2}, ${pos.y - NODE_HEIGHT / 2})`}
|
||||||
|
onMouseDown={(e) => onNodeMouseDown(e, s.ip)}
|
||||||
|
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
||||||
>
|
>
|
||||||
<rect
|
<rect
|
||||||
width={NODE_WIDTH}
|
width={NODE_WIDTH}
|
||||||
@@ -212,6 +304,29 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 12,
|
||||||
|
top: 12,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={resetLayout}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(30,41,59,0.95)',
|
||||||
|
color: UNIFI_TEXT_MUTED,
|
||||||
|
border: `1px solid ${UNIFI_CARD_BORDER}`,
|
||||||
|
}}
|
||||||
|
title="Вернуть узлы в круговую раскладку"
|
||||||
|
>
|
||||||
|
<IconRefresh size={14} className="me-1" />
|
||||||
|
Сбросить раскладку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user