Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m38s
793 lines
30 KiB
React
793 lines
30 KiB
React
import { useMemo, useCallback, useState, useRef, useEffect } from 'react';
|
||
import { IconRefresh, IconMaximize, IconMinimize, IconClock } from '@tabler/icons-react';
|
||
|
||
const FLAG_CDN = 'https://flagcdn.com';
|
||
|
||
const UNIFI_BG = '#0f172a';
|
||
const UNIFI_BG_DEEP = '#0b1220';
|
||
const UNIFI_CARD_BG = '#1e293b';
|
||
const UNIFI_CARD_BORDER = '#334155';
|
||
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_MUTED = '#94a3b8';
|
||
const NODE_WIDTH = 124;
|
||
const NODE_HEIGHT = 64;
|
||
const STORAGE_KEY = 'network-map-unifi-positions';
|
||
|
||
function computeInitialLayout(servers, size) {
|
||
const positions = {};
|
||
if (!servers.length) return positions;
|
||
|
||
// Группируем: сначала home, затем jumphost, затем exit, затем остальные
|
||
const homes = servers.filter((s) => String(s.type || '').toLowerCase() === 'home');
|
||
const jumphosts = servers.filter((s) => String(s.type || '').toLowerCase() === 'jumphost');
|
||
const exits = servers.filter((s) => String(s.type || '').toLowerCase() === 'exit');
|
||
const others = servers.filter((s) => {
|
||
const t = String(s.type || '').toLowerCase();
|
||
return t !== 'home' && t !== 'jumphost' && t !== 'exit';
|
||
});
|
||
|
||
const columns = [homes, jumphosts, exits, others].filter((col) => col.length > 0);
|
||
const colCount = columns.length || 1;
|
||
const paddingX = 80;
|
||
const usableWidth = Math.max(200, size.w - paddingX * 2);
|
||
const colStep = usableWidth / Math.max(1, colCount - 1);
|
||
const baseX = paddingX;
|
||
|
||
columns.forEach((col, colIdx) => {
|
||
// В пределах колонки сортируем по dns/ip для стабильности/
|
||
const sorted = [...col].sort((a, b) => {
|
||
const la = (a.dns || a.ip || '').toString();
|
||
const lb = (b.dns || b.ip || '').toString();
|
||
return la.localeCompare(lb);
|
||
});
|
||
const n = sorted.length;
|
||
const x = baseX + colStep * colIdx;
|
||
const topPadding = 60;
|
||
const bottomPadding = 60;
|
||
const usableHeight = Math.max(120, size.h - topPadding - bottomPadding);
|
||
sorted.forEach((s, i) => {
|
||
const t = n > 1 ? i / (n - 1) : 0.5;
|
||
const y = topPadding + usableHeight * t;
|
||
positions[String(s.ip)] = { x, y };
|
||
});
|
||
});
|
||
|
||
return positions;
|
||
}
|
||
|
||
/**
|
||
* Карта сети в стиле UNIFI: тёмный фон, простые иконки узлов, зелёные линии, пинг на рёбрах.
|
||
* Узлы можно перетаскивать; раскладка сохраняется в localStorage. Круг — только начальная раскладка.
|
||
*/
|
||
function formatMbps(bps) {
|
||
if (bps == null || Number.isNaN(bps)) return '—';
|
||
const mbps = bps / 1_000_000;
|
||
if (!Number.isFinite(mbps)) return '—';
|
||
return `${mbps.toFixed(1)} Мбит/с`;
|
||
}
|
||
|
||
/** Короткий формат скорости для подписи на ребре: "↓287 ↑170 Мбит/с" */
|
||
function formatSpeedShort(downloadBps, uploadBps) {
|
||
const fmt = (v) => {
|
||
if (v == null || Number.isNaN(v)) return '—';
|
||
const mbps = v / 1_000_000;
|
||
if (!Number.isFinite(mbps)) return '—';
|
||
return mbps >= 10 ? Math.round(mbps) : mbps.toFixed(1);
|
||
};
|
||
const down = fmt(downloadBps);
|
||
const up = fmt(uploadBps);
|
||
if (down === '—' && up === '—') return null;
|
||
if (down === '—') return `↑${up} Мбит/с`;
|
||
if (up === '—') return `↓${down} Мбит/с`;
|
||
return `↓${down} ↑${up} Мбит/с`;
|
||
}
|
||
|
||
function speedKey(key1, key2) {
|
||
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({
|
||
servers = [],
|
||
connections = [],
|
||
pingMap = {},
|
||
speedMap = {},
|
||
pingStaleMap = {},
|
||
speedStaleMap = {},
|
||
onRefreshPingForConnection,
|
||
onRefreshSpeedForConnection,
|
||
pingLoading = false,
|
||
speedLoading = false,
|
||
}) {
|
||
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);
|
||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||
const [hoveredEdgeKey, setHoveredEdgeKey] = useState(null);
|
||
const [selectedEdge, setSelectedEdge] = useState(null);
|
||
|
||
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();
|
||
}, []);
|
||
|
||
// Трек полноэкранного режима
|
||
useEffect(() => {
|
||
const handler = () => {
|
||
const fs = Boolean(document.fullscreenElement);
|
||
setIsFullscreen(fs);
|
||
};
|
||
document.addEventListener('fullscreenchange', handler);
|
||
return () => document.removeEventListener('fullscreenchange', handler);
|
||
}, []);
|
||
|
||
// Инициализация позиций: из 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 initial = computeInitialLayout(servers, size);
|
||
const merged = {};
|
||
servers.forEach((s, i) => {
|
||
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] = initial[ip] ?? { x: size.w / 2, y: size.h / 2 };
|
||
}
|
||
});
|
||
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 initial = computeInitialLayout(servers, size);
|
||
setPositions(initial);
|
||
try {
|
||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(initial));
|
||
} 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 edgePingKey = (a, b) => [String(a), String(b)].sort().join(':');
|
||
|
||
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 ekey = edgePingKey(from, to);
|
||
const ms = pingMap[ekey];
|
||
const pingStale = pingStaleMap[ekey] === true;
|
||
const pingLabel = typeof ms === 'number' ? `${ms} ms` : null;
|
||
const sk = speedKey(c.fromKey, c.toKey);
|
||
const speed = speedMap[sk];
|
||
const speedStale = speedStaleMap[sk] === true;
|
||
const speedLabel =
|
||
speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null)
|
||
? formatSpeedShort(speed.tcpDownloadBps, speed.tcpUploadBps)
|
||
: null;
|
||
const edgeLabel =
|
||
pingLabel && speedLabel ? `${pingLabel} • ${speedLabel}` : (pingLabel || speedLabel);
|
||
return {
|
||
edgeKey: `${from}-${to}-${c.tunnelType || ''}`,
|
||
from,
|
||
to,
|
||
p1,
|
||
p2,
|
||
pingLabel,
|
||
speedLabel,
|
||
edgeLabel,
|
||
pingStale,
|
||
speedStale,
|
||
stale: pingStale || speedStale,
|
||
speed,
|
||
tunnelType: c.tunnelType,
|
||
raw: c,
|
||
};
|
||
}).filter((e) => e.p1 && e.p2);
|
||
}, [connections, positions, pingMap, speedMap, pingStaleMap, speedStaleMap]);
|
||
|
||
// Вычисляем старт/финиш и одну контрольную точку:
|
||
// рёбра выходят из карточек под 90°, дальше сразу плавная дуга без прямых сегментов.
|
||
const computeCurvePoints = useCallback((e) => {
|
||
const { p1, p2 } = e;
|
||
const dx = p2.x - p1.x;
|
||
const dy = p2.y - p1.y;
|
||
const adx = Math.abs(dx);
|
||
const ady = Math.abs(dy);
|
||
|
||
let sx = p1.x;
|
||
let sy = p1.y;
|
||
let ex = p2.x;
|
||
let ey = p2.y;
|
||
|
||
if (adx >= ady) {
|
||
const sign = dx >= 0 ? 1 : -1;
|
||
sx = p1.x + sign * (NODE_WIDTH / 2);
|
||
sy = p1.y;
|
||
ex = p2.x - sign * (NODE_WIDTH / 2);
|
||
ey = p2.y;
|
||
} else {
|
||
const sign = dy >= 0 ? 1 : -1;
|
||
sx = p1.x;
|
||
sy = p1.y + sign * (NODE_HEIGHT / 2);
|
||
ex = p2.x;
|
||
ey = p2.y - sign * (NODE_HEIGHT / 2);
|
||
}
|
||
|
||
const ddx = ex - sx;
|
||
const ddy = ey - sy;
|
||
const len = Math.hypot(ddx, ddy) || 1;
|
||
|
||
const mx = (sx + ex) / 2;
|
||
const my = (sy + ey) / 2;
|
||
const baseOffset = Math.min(40, len / 2);
|
||
const perpX = (-ddy / len) * baseOffset;
|
||
const perpY = (ddx / len) * baseOffset;
|
||
const cpx = mx + perpX;
|
||
const cpy = my + perpY;
|
||
const perpLen = Math.hypot(perpX, perpY) || 1;
|
||
const ux = perpX / perpLen;
|
||
const uy = perpY / perpLen;
|
||
|
||
return { sx, sy, ex, ey, cpx, cpy, mx, my, perpX: ux, perpY: uy };
|
||
}, []);
|
||
|
||
const pathD = useCallback((e) => {
|
||
const { sx, sy, ex, ey, cpx, cpy } = computeCurvePoints(e);
|
||
return `M ${sx} ${sy} Q ${cpx} ${cpy} ${ex} ${ey}`;
|
||
}, [computeCurvePoints]);
|
||
|
||
/** Точка для подписи: середина ребра + смещение по перпендикуляру (чтобы подпись была у ребра, а не уезжала) */
|
||
const labelPlacement = useCallback((e, level = 0) => {
|
||
const { mx, my, perpX, perpY } = computeCurvePoints(e);
|
||
const baseOffset = 18;
|
||
const stepOffset = 26;
|
||
const x = mx + perpX * (baseOffset + level * stepOffset);
|
||
const y = my + perpY * (baseOffset + level * stepOffset);
|
||
return { x, y };
|
||
}, [computeCurvePoints]);
|
||
|
||
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',
|
||
userSelect: 'none',
|
||
}}
|
||
>
|
||
<svg
|
||
ref={svgRef}
|
||
width="100%"
|
||
height="100%"
|
||
viewBox={`0 0 ${size.w} ${size.h}`}
|
||
preserveAspectRatio="xMidYMid meet"
|
||
style={{ display: 'block' }}
|
||
>
|
||
<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>
|
||
{(() => {
|
||
const used = [];
|
||
const LABEL_W = 118;
|
||
const LABEL_H = 22;
|
||
return edgesWithPing.map((e, idx) => {
|
||
const dimFactor =
|
||
hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.25 : 1;
|
||
const strokeWidth = hoveredEdgeKey === e.edgeKey ? 3 : 2;
|
||
const edgeColor = getTunnelColor(e.tunnelType);
|
||
return (
|
||
<g
|
||
key={e.edgeKey || `${e.from}-${e.to}-${idx}`}
|
||
onMouseEnter={() => setHoveredEdgeKey(e.edgeKey)}
|
||
onMouseLeave={() =>
|
||
setHoveredEdgeKey((prev) => (prev === e.edgeKey ? null : prev))
|
||
}
|
||
onClick={() => setSelectedEdge(e)}
|
||
style={{ cursor: 'pointer' }}
|
||
>
|
||
<path
|
||
d={pathD(e)}
|
||
fill="none"
|
||
stroke={edgeColor}
|
||
strokeWidth={strokeWidth}
|
||
strokeLinecap="round"
|
||
opacity={dimFactor}
|
||
filter="url(#edgeGlow)"
|
||
/>
|
||
{(() => {
|
||
if (!e.edgeLabel) return null;
|
||
const minDist = 70;
|
||
let level = 0;
|
||
let x; let y;
|
||
while (level < 10) {
|
||
const pos = labelPlacement(e, level);
|
||
x = pos.x;
|
||
y = pos.y;
|
||
const overlaps = used.some(
|
||
(p) => Math.hypot(p.x - x, p.y - y) < minDist
|
||
);
|
||
if (!overlaps) break;
|
||
level += 1;
|
||
}
|
||
used.push({ x, y });
|
||
|
||
const pad = 10;
|
||
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 (
|
||
<g>
|
||
<rect
|
||
x={x - w / 2}
|
||
y={y - h / 2}
|
||
width={w}
|
||
height={h}
|
||
rx={7}
|
||
fill="rgba(2,6,23,0.9)"
|
||
stroke={isStale ? staleColor : edgeColor}
|
||
strokeWidth={1}
|
||
strokeDasharray={isStale ? '2,2' : 'none'}
|
||
/>
|
||
{isStale && (
|
||
<circle
|
||
cx={x - w / 2 + 10}
|
||
cy={y}
|
||
r={4.4}
|
||
fill={staleColor}
|
||
opacity={0.95}
|
||
/>
|
||
)}
|
||
<text
|
||
x={textX}
|
||
y={y}
|
||
textAnchor="middle"
|
||
dominantBaseline="middle"
|
||
fill={isStale ? staleColor : edgeColor}
|
||
fontSize={11}
|
||
fontWeight={600}
|
||
>
|
||
{e.edgeLabel}
|
||
</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 || '?';
|
||
const typeMeta = getNodeTypeMeta(s.type);
|
||
const isDragging = drag?.nodeId === s.ip;
|
||
return (
|
||
<g
|
||
key={s.ip}
|
||
transform={`translate(${pos.x - NODE_WIDTH / 2}, ${pos.y - NODE_HEIGHT / 2})`}
|
||
onMouseDown={(e) => onNodeMouseDown(e, s.ip)}
|
||
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
||
>
|
||
<rect
|
||
width={NODE_WIDTH}
|
||
height={NODE_HEIGHT}
|
||
rx={8}
|
||
ry={8}
|
||
fill={UNIFI_CARD_BG}
|
||
stroke={UNIFI_CARD_BORDER}
|
||
strokeWidth={1}
|
||
filter="url(#nodeShadow)"
|
||
/>
|
||
<g transform={`translate(${NODE_WIDTH / 2}, ${NODE_HEIGHT / 2})`}>
|
||
{/* Флаг страны в левом верхнем углу карточки (картинка, т.к. эмодзи в SVG часто отображаются как буквы) */}
|
||
{s.country && (() => {
|
||
const cc = String(s.country).trim().toLowerCase();
|
||
if (cc.length !== 2) return null;
|
||
const flagW = 22;
|
||
const flagH = 16;
|
||
return (
|
||
<image
|
||
href={`${FLAG_CDN}/w40/${cc}.png`}
|
||
x={-NODE_WIDTH / 2 + 6}
|
||
y={-NODE_HEIGHT / 2 + 6}
|
||
width={flagW}
|
||
height={flagH}
|
||
preserveAspectRatio="xMidYMid meet"
|
||
style={{ pointerEvents: 'none' }}
|
||
className="network-map-node-flag"
|
||
>
|
||
<title>{s.country.toUpperCase()}</title>
|
||
</image>
|
||
);
|
||
})()}
|
||
{/* Иконка сервера в стиле UNIFI: прямоугольник с «портами» */}
|
||
<g transform="translate(-10, -16)">
|
||
<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={typeMeta.color} strokeWidth={1} opacity={0.8} />
|
||
<line x1={5} y1={8} x2={15} y2={8} stroke={typeMeta.color} strokeWidth={1} opacity={0.8} />
|
||
</g>
|
||
<text
|
||
x={0}
|
||
y={8}
|
||
textAnchor="middle"
|
||
fill={UNIFI_TEXT}
|
||
fontSize={11.5}
|
||
fontWeight={600}
|
||
>
|
||
{label.length > 14 ? `${label.slice(0, 13)}…` : label}
|
||
</text>
|
||
<text
|
||
x={0}
|
||
y={22}
|
||
textAnchor="middle"
|
||
fill={UNIFI_TEXT_MUTED}
|
||
fontSize={9.5}
|
||
>
|
||
{s.ip}
|
||
</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>
|
||
);
|
||
})}
|
||
</svg>
|
||
{selectedEdge && (() => {
|
||
const fromServer = servers.find((s) => String(s.ip) === selectedEdge.from);
|
||
const toServer = servers.find((s) => String(s.ip) === selectedEdge.to);
|
||
const ekey = edgePingKey(selectedEdge.from, selectedEdge.to);
|
||
const pingMs = pingMap[ekey];
|
||
const pingStale = pingStaleMap[ekey] === true;
|
||
const sk = speedKey(selectedEdge.raw?.fromKey, selectedEdge.raw?.toKey);
|
||
const speed = speedMap[sk] ?? selectedEdge.speed;
|
||
const speedStale = speedStaleMap[sk] === true;
|
||
const canPingFrom =
|
||
fromServer &&
|
||
['jumphost', 'home'].includes(String(fromServer.type || '').toLowerCase()) &&
|
||
selectedEdge.raw?.internalFromTo;
|
||
const canPingTo =
|
||
toServer &&
|
||
['jumphost', 'home'].includes(String(toServer.type || '').toLowerCase()) &&
|
||
selectedEdge.raw?.internalToFrom;
|
||
const canPing = Boolean(canPingFrom || canPingTo);
|
||
const canSpeed =
|
||
Boolean(selectedEdge.raw?.speedTestServerId && selectedEdge.raw?.interfaceName);
|
||
return (
|
||
<div
|
||
className="network-map-edge-modal-backdrop"
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
background: 'rgba(15,23,42,0.55)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 50,
|
||
}}
|
||
onClick={() => setSelectedEdge(null)}
|
||
>
|
||
<div
|
||
className="card shadow-lg"
|
||
style={{
|
||
minWidth: 360,
|
||
maxWidth: 520,
|
||
background: '#0f172a',
|
||
color: '#e5e7eb',
|
||
borderRadius: 12,
|
||
border: `1px solid ${UNIFI_CARD_BORDER}`,
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="card-header d-flex align-items-center justify-content-between">
|
||
<div className="fw-semibold">Детали соединения</div>
|
||
<button
|
||
type="button"
|
||
className="btn-close btn-close-white"
|
||
aria-label="Закрыть"
|
||
onClick={() => setSelectedEdge(null)}
|
||
style={{ filter: 'invert(1)' }}
|
||
/>
|
||
</div>
|
||
<div className="card-body" style={{ fontSize: 13 }}>
|
||
<div className="mb-2">
|
||
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}>
|
||
От
|
||
</div>
|
||
<div>
|
||
<strong>{fromServer?.dns || fromServer?.ip || selectedEdge.from}</strong>{' '}
|
||
<span className="text-muted">({fromServer?.ip || '—'})</span>{' '}
|
||
<span className="badge bg-blue-lt text-blue ms-1">
|
||
{String(fromServer?.type || '').toUpperCase() || 'SERVER'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="mb-3">
|
||
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}>
|
||
К
|
||
</div>
|
||
<div>
|
||
<strong>{toServer?.dns || toServer?.ip || selectedEdge.to}</strong>{' '}
|
||
<span className="text-muted">({toServer?.ip || '—'})</span>{' '}
|
||
<span className="badge bg-blue-lt text-blue ms-1">
|
||
{String(toServer?.type || '').toUpperCase() || 'SERVER'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="mb-3">
|
||
<div className="text-muted text-uppercase mb-1" style={{ fontSize: 11 }}>
|
||
Туннель
|
||
</div>
|
||
<div>
|
||
<span className="badge bg-green-lt text-green me-2">
|
||
{selectedEdge.tunnelType || 'TUNNEL'}
|
||
</span>
|
||
{selectedEdge.raw?.internalFromTo && (
|
||
<span className="text-muted">
|
||
{selectedEdge.raw.internalToFrom} ⇄ {selectedEdge.raw.internalFromTo}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="mb-3">
|
||
<div className="text-muted text-uppercase mb-1 d-flex align-items-center" style={{ fontSize: 11 }}>
|
||
Пинг
|
||
{pingStale && (
|
||
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: 9 }}>
|
||
<IconClock size={10} className="me-1" />
|
||
Устаревшие данные
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div>
|
||
{typeof pingMs === 'number' ? `${pingMs} ms` : 'нет данных'}
|
||
</div>
|
||
</div>
|
||
{speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null) && (
|
||
<div className="mb-1">
|
||
<div className="text-muted text-uppercase mb-1 d-flex align-items-center" style={{ fontSize: 11 }}>
|
||
Скорость по туннелю
|
||
{speed.cached && (
|
||
<span className="ms-1 badge bg-secondary" style={{ fontSize: 10 }}>кеш</span>
|
||
)}
|
||
{speedStale && (
|
||
<span className="ms-1 badge bg-warning text-dark" style={{ fontSize: 9 }}>
|
||
<IconClock size={10} className="me-1" />
|
||
Устаревшие данные
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<div>
|
||
<span className="text-muted">↓ Download </span>
|
||
{formatMbps(speed.tcpDownloadBps)}
|
||
</div>
|
||
<div>
|
||
<span className="text-muted">↑ Upload </span>
|
||
{formatMbps(speed.tcpUploadBps)}
|
||
</div>
|
||
{speed.durationSeconds != null && (
|
||
<div className="text-muted small mt-1">
|
||
Замер за {speed.durationSeconds} сек
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="mt-3 d-flex flex-wrap gap-2 justify-content-end">
|
||
<button
|
||
type="button"
|
||
className="btn btn-outline-primary btn-sm"
|
||
disabled={!canPing || pingLoading || !onRefreshPingForConnection}
|
||
onClick={() => onRefreshPingForConnection && onRefreshPingForConnection(selectedEdge.raw)}
|
||
>
|
||
<IconRefresh
|
||
size={14}
|
||
className={pingLoading ? 'spin me-1' : 'me-1'}
|
||
/>
|
||
{pingLoading ? 'Пинг…' : 'Проверить пинг'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-outline-primary btn-sm"
|
||
disabled={!canSpeed || speedLoading || !onRefreshSpeedForConnection}
|
||
onClick={() => onRefreshSpeedForConnection && onRefreshSpeedForConnection(selectedEdge.raw)}
|
||
>
|
||
<IconRefresh
|
||
size={14}
|
||
className={speedLoading ? 'spin me-1' : 'me-1'}
|
||
/>
|
||
{speedLoading ? 'Скорость…' : 'Проверить скорость'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
right: 12,
|
||
top: 12,
|
||
zIndex: 10,
|
||
}}
|
||
>
|
||
<div className="btn-group btn-group-sm">
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
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>
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
onClick={() => {
|
||
const el = containerRef.current;
|
||
if (!el) return;
|
||
if (!document.fullscreenElement) {
|
||
el.requestFullscreen?.();
|
||
} else {
|
||
document.exitFullscreen?.();
|
||
}
|
||
}}
|
||
style={{
|
||
background: 'rgba(30,41,59,0.95)',
|
||
color: UNIFI_TEXT_MUTED,
|
||
border: `1px solid ${UNIFI_CARD_BORDER}`,
|
||
borderLeft: 'none',
|
||
}}
|
||
title={isFullscreen ? 'Выйти из полноэкранного режима' : 'Открыть карту на весь экран'}
|
||
>
|
||
{isFullscreen ? <IconMinimize size={14} /> : <IconMaximize size={14} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|