refactor(NetworkMapDashboard): replace GraphView with NetworkMapUnifi for improved network visualization
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:
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import { IconRefresh, IconTopologyRing } from '@tabler/icons-react';
|
import { IconRefresh, IconTopologyRing } from '@tabler/icons-react';
|
||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
import GraphView from './GraphView.jsx';
|
import NetworkMapUnifi from './NetworkMapUnifi.jsx';
|
||||||
|
|
||||||
/** Параллельно выполняем промисы с лимитом одновременных */
|
/** Параллельно выполняем промисы с лимитом одновременных */
|
||||||
async function runWithLimit(tasks, limit = 4) {
|
async function runWithLimit(tasks, limit = 4) {
|
||||||
@@ -174,7 +174,7 @@ export default function NetworkMapDashboard() {
|
|||||||
Нет серверов. Добавьте серверы и связи в разделе «Серверы».
|
Нет серверов. Добавьте серверы и связи в разделе «Серверы».
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<GraphView
|
<NetworkMapUnifi
|
||||||
servers={servers}
|
servers={servers}
|
||||||
connections={connections}
|
connections={connections}
|
||||||
pingMap={pingMap}
|
pingMap={pingMap}
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { useMemo, useCallback, useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
const UNIFI_BG = '#0f172a';
|
||||||
|
const UNIFI_CARD_BG = '#1e293b';
|
||||||
|
const UNIFI_CARD_BORDER = '#334155';
|
||||||
|
const UNIFI_GREEN = '#22c55e';
|
||||||
|
const UNIFI_TEXT = '#f1f5f9';
|
||||||
|
const UNIFI_TEXT_MUTED = '#94a3b8';
|
||||||
|
const NODE_WIDTH = 88;
|
||||||
|
const NODE_HEIGHT = 52;
|
||||||
|
const CIRCLE_RADIUS = 240;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Карта сети в стиле UNIFI: тёмный фон, простые иконки узлов, зелёные линии, пинг на рёбрах.
|
||||||
|
* @param {Array} servers - список серверов { ip, dns, ... }
|
||||||
|
* @param {Array} connections - связи { from, to, tunnelType? }
|
||||||
|
* @param {Object} pingMap - ключ "fromIp:toIp" -> число (мс)
|
||||||
|
*/
|
||||||
|
export default function NetworkMapUnifi({ servers = [], connections = [], pingMap = {} }) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const [size, setSize] = useState({ w: 800, h: 520 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const { width, height } = entries[0]?.contentRect ?? {};
|
||||||
|
if (width > 0 && height > 0) setSize({ w: width, h: height });
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
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 = {};
|
||||||
|
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 };
|
||||||
|
}, [servers, size]);
|
||||||
|
|
||||||
|
const edgesWithPing = useMemo(() => {
|
||||||
|
return (connections || []).map((c) => {
|
||||||
|
const from = String(c.from);
|
||||||
|
const to = String(c.to);
|
||||||
|
const p1 = positions[from];
|
||||||
|
const p2 = positions[to];
|
||||||
|
const fwd = pingMap[`${from}:${to}`];
|
||||||
|
const rev = pingMap[`${to}:${from}`];
|
||||||
|
const parts = [];
|
||||||
|
if (typeof fwd === 'number') parts.push(`→ ${fwd} ms`);
|
||||||
|
if (typeof rev === 'number') parts.push(`← ${rev} ms`);
|
||||||
|
const pingLabel = parts.length ? parts.join(' ') : null;
|
||||||
|
return {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
p1,
|
||||||
|
p2,
|
||||||
|
pingLabel,
|
||||||
|
tunnelType: c.tunnelType,
|
||||||
|
};
|
||||||
|
}).filter((e) => e.p1 && e.p2);
|
||||||
|
}, [connections, positions, pingMap]);
|
||||||
|
|
||||||
|
const pathD = 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;
|
||||||
|
return `M ${p1.x} ${p1.y} Q ${cpx} ${cpy} ${p2.x} ${p2.y}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
return { x: lx, y: ly };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (servers.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="network-map-unifi"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 520,
|
||||||
|
minHeight: 400,
|
||||||
|
background: UNIFI_BG,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
viewBox={`0 0 ${size.w} ${size.h}`}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
>
|
||||||
|
{/* Рёбра: зелёные кривые (стиль UNIFI — проводные соединения) */}
|
||||||
|
<g>
|
||||||
|
{edgesWithPing.map((e, idx) => (
|
||||||
|
<g key={`${e.from}-${e.to}-${idx}`}>
|
||||||
|
<path
|
||||||
|
d={pathD(e)}
|
||||||
|
fill="none"
|
||||||
|
stroke={UNIFI_GREEN}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{e.pingLabel && (() => {
|
||||||
|
const lp = labelPoint(e);
|
||||||
|
const pad = 6;
|
||||||
|
const w = e.pingLabel.length * 6 + pad * 2;
|
||||||
|
const h = 18;
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<rect x={lp.x - w / 2} y={lp.y - h / 2} width={w} height={h} rx={4} fill="rgba(15,23,42,0.9)" stroke={UNIFI_GREEN} strokeWidth={1} />
|
||||||
|
<text
|
||||||
|
x={lp.x}
|
||||||
|
y={lp.y}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fill={UNIFI_GREEN}
|
||||||
|
fontSize={11}
|
||||||
|
fontWeight={600}
|
||||||
|
>
|
||||||
|
{e.pingLabel}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Узлы: карточки серверов в стиле UNIFI */}
|
||||||
|
{servers.map((s) => {
|
||||||
|
const pos = positions[String(s.ip)];
|
||||||
|
if (!pos) return null;
|
||||||
|
const label = s.dns?.split('.')[0] || s.ip || '?';
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={s.ip}
|
||||||
|
transform={`translate(${pos.x - NODE_WIDTH / 2}, ${pos.y - NODE_HEIGHT / 2})`}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
width={NODE_WIDTH}
|
||||||
|
height={NODE_HEIGHT}
|
||||||
|
rx={8}
|
||||||
|
ry={8}
|
||||||
|
fill={UNIFI_CARD_BG}
|
||||||
|
stroke={UNIFI_CARD_BORDER}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<g transform={`translate(${NODE_WIDTH / 2}, ${NODE_HEIGHT / 2})`}>
|
||||||
|
{/* Иконка сервера в стиле UNIFI: прямоугольник с «портами» */}
|
||||||
|
<g transform="translate(-10, -16)">
|
||||||
|
<rect x={2} y={0} width={16} height={12} rx={2} fill="none" stroke={UNIFI_GREEN} strokeWidth={1.5} />
|
||||||
|
<line x1={5} y1={4} x2={15} y2={4} stroke={UNIFI_GREEN} strokeWidth={1} opacity={0.8} />
|
||||||
|
<line x1={5} y1={8} x2={15} y2={8} stroke={UNIFI_GREEN} strokeWidth={1} opacity={0.8} />
|
||||||
|
</g>
|
||||||
|
<text
|
||||||
|
x={0}
|
||||||
|
y={8}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={UNIFI_TEXT}
|
||||||
|
fontSize={12}
|
||||||
|
fontWeight={600}
|
||||||
|
>
|
||||||
|
{label.length > 12 ? label.slice(0, 11) + '…' : label}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={0}
|
||||||
|
y={22}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={UNIFI_TEXT_MUTED}
|
||||||
|
fontSize={10}
|
||||||
|
>
|
||||||
|
{s.ip}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user