diff --git a/frontend/src/NetworkMapDashboard.jsx b/frontend/src/NetworkMapDashboard.jsx
index 62e6713..c99ee7a 100644
--- a/frontend/src/NetworkMapDashboard.jsx
+++ b/frontend/src/NetworkMapDashboard.jsx
@@ -34,16 +34,37 @@ export default function NetworkMapDashboard() {
const [error, setError] = useState(null);
const pingAbortRef = useRef(false);
+ /** Связи строятся из интерфейсов раздела «Сетевые настройки» (/network-config), tunnelInterfaces */
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
- const [serversRes, connRes] = await Promise.all([
+ const [serversRes, configRes] = await Promise.all([
api.get('/servers'),
- api.get('/server-connections'),
+ api.get('/network-config').catch(() => ({ data: null })),
]);
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);
setConnections(connList);
} catch (e) {
@@ -156,7 +177,7 @@ export default function NetworkMapDashboard() {
}
- meta="Серверы и пинг между ними (на рёбрах — задержка в мс)"
+ meta="Связи из раздела «Сетевые настройки» → Интерфейсы; на рёбрах — пинг (мс)"
actions={
{servers.length === 0 ? (
- Нет серверов. Добавьте серверы и связи в разделе «Серверы».
+ Нет серверов. Добавьте серверы в разделе «Серверы».
) : (
{
+ 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: тёмный фон, простые иконки узлов, зелёные линии, пинг на рёбрах.
- * @param {Array} servers - список серверов { ip, dns, ... }
- * @param {Array} connections - связи { from, to, tunnelType? }
- * @param {Object} pingMap - ключ "fromIp:toIp" -> число (мс)
+ * Узлы можно перетаскивать; раскладка сохраняется в localStorage. Круг — только начальная раскладка.
*/
export default function NetworkMapUnifi({ servers = [], connections = [], pingMap = {} }) {
const containerRef = useRef(null);
+ const svgRef = useRef(null);
const [size, setSize] = useState({ w: 800, h: 520 });
+ const [positions, setPositions] = useState({});
+ const [drag, setDrag] = useState(null);
useEffect(() => {
const el = containerRef.current;
@@ -31,23 +51,90 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
return () => ro.disconnect();
}, []);
- const { positions } = useMemo(() => {
- const n = servers.length;
- if (n === 0) return { positions: {} };
- 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 = {};
+ // Инициализация позиций: из localStorage или по кругу
+ useEffect(() => {
+ if (servers.length === 0) {
+ setPositions({});
+ return;
+ }
+ 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) => {
- 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),
- };
+ 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 };
+ }
});
- 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]);
+ 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(() => {
return (connections || []).map((c) => {
const from = String(c.from);
@@ -116,9 +203,11 @@ export default function NetworkMapUnifi({ servers = [], connections = [], pingMa
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
+ userSelect: 'none',
}}
>