refactor(NetworkMapDashboard): replace GraphView with NetworkMapUnifi for improved network visualization
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m7s

This commit is contained in:
2026-02-17 16:12:46 +07:00
parent 5ede9e079d
commit f8a50581e4
2 changed files with 219 additions and 2 deletions
+2 -2
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import api from './lib/api.js';
import { IconRefresh, IconTopologyRing } from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
import GraphView from './GraphView.jsx';
import NetworkMapUnifi from './NetworkMapUnifi.jsx';
/** Параллельно выполняем промисы с лимитом одновременных */
async function runWithLimit(tasks, limit = 4) {
@@ -174,7 +174,7 @@ export default function NetworkMapDashboard() {
Нет серверов. Добавьте серверы и связи в разделе «Серверы».
</div>
) : (
<GraphView
<NetworkMapUnifi
servers={servers}
connections={connections}
pingMap={pingMap}
+217
View File
@@ -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>
);
}