feat(network-map): enhance node representation and edge styling with new color schemes and layout adjustments

This commit is contained in:
2026-02-24 22:54:41 +07:00
parent 80de7160b9
commit dcf1853efb
+118 -66
View File
@@ -4,13 +4,17 @@ import { IconRefresh, IconMaximize, IconMinimize, IconClock } from '@tabler/icon
const FLAG_CDN = 'https://flagcdn.com'; const FLAG_CDN = 'https://flagcdn.com';
const UNIFI_BG = '#0f172a'; const UNIFI_BG = '#0f172a';
const UNIFI_BG_DEEP = '#0b1220';
const UNIFI_CARD_BG = '#1e293b'; const UNIFI_CARD_BG = '#1e293b';
const UNIFI_CARD_BORDER = '#334155'; const UNIFI_CARD_BORDER = '#334155';
const UNIFI_GREEN = '#22c55e'; const UNIFI_GREEN = '#22c55e';
const UNIFI_GREEN_SOFT = '#34d399';
const UNIFI_BLUE_SOFT = '#38bdf8';
const UNIFI_WARNING = '#f59e0b';
const UNIFI_TEXT = '#f1f5f9'; const UNIFI_TEXT = '#f1f5f9';
const UNIFI_TEXT_MUTED = '#94a3b8'; const UNIFI_TEXT_MUTED = '#94a3b8';
const NODE_WIDTH = 88; const NODE_WIDTH = 124;
const NODE_HEIGHT = 52; const NODE_HEIGHT = 64;
const STORAGE_KEY = 'network-map-unifi-positions'; const STORAGE_KEY = 'network-map-unifi-positions';
function computeInitialLayout(servers, size) { function computeInitialLayout(servers, size) {
@@ -86,6 +90,21 @@ function speedKey(key1, key2) {
return [String(key1), String(key2)].sort().join(':'); return [String(key1), String(key2)].sort().join(':');
} }
function getNodeTypeMeta(type) {
const t = String(type || '').toLowerCase();
if (t === 'home') return { label: 'HOME', color: '#60a5fa' };
if (t === 'jumphost') return { label: 'JUMP', color: '#34d399' };
if (t === 'exit') return { label: 'EXIT', color: '#f59e0b' };
return { label: 'NODE', color: '#94a3b8' };
}
function getTunnelColor(tunnelType) {
const t = String(tunnelType || '').toLowerCase();
if (t.includes('wireguard') || t === 'wg') return UNIFI_BLUE_SOFT;
if (t.includes('ipsec')) return UNIFI_GREEN;
return UNIFI_GREEN_SOFT;
}
export default function NetworkMapUnifi({ export default function NetworkMapUnifi({
servers = [], servers = [],
connections = [], connections = [],
@@ -231,6 +250,8 @@ export default function NetworkMapUnifi({
speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null) speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null)
? formatSpeedShort(speed.tcpDownloadBps, speed.tcpUploadBps) ? formatSpeedShort(speed.tcpDownloadBps, speed.tcpUploadBps)
: null; : null;
const edgeLabel =
pingLabel && speedLabel ? `${pingLabel}${speedLabel}` : (pingLabel || speedLabel);
return { return {
edgeKey: `${from}-${to}-${c.tunnelType || ''}`, edgeKey: `${from}-${to}-${c.tunnelType || ''}`,
from, from,
@@ -239,8 +260,10 @@ export default function NetworkMapUnifi({
p2, p2,
pingLabel, pingLabel,
speedLabel, speedLabel,
edgeLabel,
pingStale, pingStale,
speedStale, speedStale,
stale: pingStale || speedStale,
speed, speed,
tunnelType: c.tunnelType, tunnelType: c.tunnelType,
raw: c, raw: c,
@@ -334,17 +357,34 @@ export default function NetworkMapUnifi({
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
style={{ display: 'block' }} style={{ display: 'block' }}
> >
{/* Рёбра: зелёные кривые (стиль UNIFI — проводные соединения) */} <defs>
<linearGradient id="unifiBg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={UNIFI_BG} />
<stop offset="100%" stopColor={UNIFI_BG_DEEP} />
</linearGradient>
<filter id="edgeGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="nodeShadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="1" stdDeviation="2" floodColor="#020617" floodOpacity="0.55" />
</filter>
</defs>
<rect x="0" y="0" width={size.w} height={size.h} fill="url(#unifiBg)" />
{/* Рёбра: сглаженные линии с цветовой дифференциацией туннеля */}
<g> <g>
{(() => { {(() => {
const used = []; const used = [];
const LABEL_W = 100; const LABEL_W = 118;
const LABEL_H = 18; const LABEL_H = 22;
const LABEL_GAP = 6;
return edgesWithPing.map((e, idx) => { return edgesWithPing.map((e, idx) => {
const dimFactor = const dimFactor =
hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.25 : 1; hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.25 : 1;
const strokeWidth = hoveredEdgeKey === e.edgeKey ? 3.2 : 2.5; const strokeWidth = hoveredEdgeKey === e.edgeKey ? 3 : 2;
const edgeColor = getTunnelColor(e.tunnelType);
return ( return (
<g <g
key={e.edgeKey || `${e.from}-${e.to}-${idx}`} key={e.edgeKey || `${e.from}-${e.to}-${idx}`}
@@ -358,18 +398,14 @@ export default function NetworkMapUnifi({
<path <path
d={pathD(e)} d={pathD(e)}
fill="none" fill="none"
stroke={UNIFI_GREEN} stroke={edgeColor}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
strokeLinecap="round" strokeLinecap="round"
opacity={dimFactor} opacity={dimFactor}
filter="url(#edgeGlow)"
/> />
{(() => { {(() => {
const labels = [ if (!e.edgeLabel) return null;
e.pingLabel ? { text: e.pingLabel, stale: e.pingStale } : null,
e.speedLabel ? { text: e.speedLabel, stale: e.speedStale } : null,
].filter(Boolean);
if (labels.length === 0) return null;
const totalH = labels.length * LABEL_H + (labels.length - 1) * LABEL_GAP;
const minDist = 70; const minDist = 70;
let level = 0; let level = 0;
let x; let y; let x; let y;
@@ -385,54 +421,47 @@ export default function NetworkMapUnifi({
} }
used.push({ x, y }); used.push({ x, y });
const pad = 8; const pad = 10;
const staleColor = '#f59e0b'; const isStale = e.stale === true;
const staleColor = UNIFI_WARNING;
const textWidth = e.edgeLabel.length * 6.2;
const iconWidth = isStale ? 14 : 0;
const w = Math.max(LABEL_W, Math.min(textWidth + pad * 2 + iconWidth, 210));
const h = LABEL_H;
const textX = x - (iconWidth / 2);
return ( return (
<g> <g>
{labels.map((label, i) => { <rect
const yy = y + i * (LABEL_H + LABEL_GAP) + LABEL_H / 2; x={x - w / 2}
const isStale = label.stale === true; y={y - h / 2}
const textWidth = label.text.length * 6; width={w}
const iconWidth = isStale ? 14 : 0; height={h}
const w = Math.max(LABEL_W, Math.min(textWidth + pad * 2 + iconWidth, 180)); rx={7}
const h = LABEL_H + 4; fill="rgba(2,6,23,0.9)"
const textX = x - (iconWidth / 2); stroke={isStale ? staleColor : edgeColor}
return ( strokeWidth={1}
<g key={i}> strokeDasharray={isStale ? '2,2' : 'none'}
<rect />
x={x - w / 2} {isStale && (
y={yy - h / 2} <circle
width={w} cx={x - w / 2 + 10}
height={h} cy={y}
rx={4} r={4.4}
fill="rgba(15,23,42,0.92)" fill={staleColor}
stroke={isStale ? staleColor : UNIFI_GREEN} opacity={0.95}
strokeWidth={1} />
strokeDasharray={isStale ? '2,2' : 'none'} )}
/> <text
{isStale && ( x={textX}
<circle y={y}
cx={x - w / 2 + 10} textAnchor="middle"
cy={yy} dominantBaseline="middle"
r={5} fill={isStale ? staleColor : edgeColor}
fill={staleColor} fontSize={11}
opacity={0.9} fontWeight={600}
/> >
)} {e.edgeLabel}
<text </text>
x={textX}
y={yy}
textAnchor="middle"
dominantBaseline="middle"
fill={isStale ? staleColor : UNIFI_GREEN}
fontSize={i === 0 ? 12 : 11}
fontWeight={600}
>
{label.text}
</text>
</g>
);
})}
</g> </g>
); );
})()} })()}
@@ -447,6 +476,7 @@ export default function NetworkMapUnifi({
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 typeMeta = getNodeTypeMeta(s.type);
const isDragging = drag?.nodeId === s.ip; const isDragging = drag?.nodeId === s.ip;
return ( return (
<g <g
@@ -463,6 +493,7 @@ export default function NetworkMapUnifi({
fill={UNIFI_CARD_BG} fill={UNIFI_CARD_BG}
stroke={UNIFI_CARD_BORDER} stroke={UNIFI_CARD_BORDER}
strokeWidth={1} strokeWidth={1}
filter="url(#nodeShadow)"
/> />
<g transform={`translate(${NODE_WIDTH / 2}, ${NODE_HEIGHT / 2})`}> <g transform={`translate(${NODE_WIDTH / 2}, ${NODE_HEIGHT / 2})`}>
{/* Флаг страны в левом верхнем углу карточки (картинка, т.к. эмодзи в SVG часто отображаются как буквы) */} {/* Флаг страны в левом верхнем углу карточки (картинка, т.к. эмодзи в SVG часто отображаются как буквы) */}
@@ -488,29 +519,50 @@ export default function NetworkMapUnifi({
})()} })()}
{/* Иконка сервера в стиле UNIFI: прямоугольник с «портами» */} {/* Иконка сервера в стиле UNIFI: прямоугольник с «портами» */}
<g transform="translate(-10, -16)"> <g transform="translate(-10, -16)">
<rect x={2} y={0} width={16} height={12} rx={2} fill="none" stroke={UNIFI_GREEN} strokeWidth={1.5} /> <rect x={2} y={0} width={16} height={12} rx={2} fill="none" stroke={typeMeta.color} strokeWidth={1.5} />
<line x1={5} y1={4} x2={15} y2={4} stroke={UNIFI_GREEN} strokeWidth={1} opacity={0.8} /> <line x1={5} y1={4} x2={15} y2={4} stroke={typeMeta.color} strokeWidth={1} opacity={0.8} />
<line x1={5} y1={8} x2={15} y2={8} stroke={UNIFI_GREEN} strokeWidth={1} opacity={0.8} /> <line x1={5} y1={8} x2={15} y2={8} stroke={typeMeta.color} strokeWidth={1} opacity={0.8} />
</g> </g>
<text <text
x={0} x={0}
y={8} y={8}
textAnchor="middle" textAnchor="middle"
fill={UNIFI_TEXT} fill={UNIFI_TEXT}
fontSize={12} fontSize={11.5}
fontWeight={600} fontWeight={600}
> >
{label.length > 12 ? label.slice(0, 11) + '…' : label} {label.length > 14 ? `${label.slice(0, 13)}` : label}
</text> </text>
<text <text
x={0} x={0}
y={22} y={22}
textAnchor="middle" textAnchor="middle"
fill={UNIFI_TEXT_MUTED} fill={UNIFI_TEXT_MUTED}
fontSize={10} fontSize={9.5}
> >
{s.ip} {s.ip}
</text> </text>
<rect
x={NODE_WIDTH / 2 - 42}
y={-NODE_HEIGHT / 2 + 5}
width={36}
height={14}
rx={7}
fill="rgba(15,23,42,0.9)"
stroke={typeMeta.color}
strokeWidth={1}
/>
<text
x={NODE_WIDTH / 2 - 24}
y={-NODE_HEIGHT / 2 + 15}
textAnchor="middle"
fill={typeMeta.color}
fontSize={8}
fontWeight={700}
letterSpacing={0.5}
>
{typeMeta.label}
</text>
</g> </g>
</g> </g>
); );