Files
router-lists-ui/frontend/src/NetworkMapUnifi.jsx
T
2026-02-25 00:44:19 +07:00

1142 lines
42 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
function sideVector(side) {
if (side === 'right') return { x: 1, y: 0 };
if (side === 'left') return { x: -1, y: 0 };
if (side === 'bottom') return { x: 0, y: 1 };
return { x: 0, y: -1 };
}
function anchorPoint(pos, side, slotOffset = 0) {
if (side === 'right') return { x: pos.x + NODE_WIDTH / 2, y: pos.y + slotOffset };
if (side === 'left') return { x: pos.x - NODE_WIDTH / 2, y: pos.y + slotOffset };
if (side === 'bottom') return { x: pos.x + slotOffset, y: pos.y + NODE_HEIGHT / 2 };
return { x: pos.x + slotOffset, y: pos.y - NODE_HEIGHT / 2 };
}
function clamp(val, min, max) {
return Math.max(min, Math.min(max, val));
}
function hash01(str) {
let h = 2166136261;
const s = String(str || '');
for (let i = 0; i < s.length; i += 1) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0) / 4294967295;
}
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 compactEdgeLabel = pingLabel || speedLabel || null;
const edgeLabel =
pingLabel && speedLabel ? `${pingLabel}${speedLabel}` : (pingLabel || speedLabel);
return {
edgeKey: `${from}-${to}-${c.tunnelType || ''}`,
from,
to,
p1,
p2,
pingMs: typeof ms === 'number' ? ms : null,
pingLabel,
speedLabel,
compactEdgeLabel,
edgeLabel,
pingStale,
speedStale,
stale: pingStale || speedStale,
speed,
tunnelType: c.tunnelType,
raw: c,
};
}).filter((e) => e.p1 && e.p2);
}, [connections, positions, pingMap, speedMap, pingStaleMap, speedStaleMap]);
// Роутинг рёбер: раскладываем рёбра по "портам" на сторонах узла (эффект трезубца),
// а также даём параллельным связям разный изгиб.
const routedEdges = useMemo(() => {
const nodePortBuckets = new Map();
const edgeRoutes = new Map();
const pairBuckets = new Map();
const slotStep = 9;
const getEdgeSides = (e) => {
const dx = e.p2.x - e.p1.x;
const dy = e.p2.y - e.p1.y;
if (Math.abs(dx) >= Math.abs(dy)) {
return dx >= 0
? { fromSide: 'right', toSide: 'left' }
: { fromSide: 'left', toSide: 'right' };
}
return dy >= 0
? { fromSide: 'bottom', toSide: 'top' }
: { fromSide: 'top', toSide: 'bottom' };
};
const getSortValue = (side, targetPos) =>
side === 'left' || side === 'right' ? targetPos.y : targetPos.x;
edgesWithPing.forEach((e, idx) => {
const { fromSide, toSide } = getEdgeSides(e);
if (!nodePortBuckets.has(e.from)) nodePortBuckets.set(e.from, {});
if (!nodePortBuckets.has(e.to)) nodePortBuckets.set(e.to, {});
const fromBuckets = nodePortBuckets.get(e.from);
const toBuckets = nodePortBuckets.get(e.to);
fromBuckets[fromSide] = fromBuckets[fromSide] || [];
toBuckets[toSide] = toBuckets[toSide] || [];
fromBuckets[fromSide].push({
idx,
role: 'from',
sortValue: getSortValue(fromSide, e.p2),
});
toBuckets[toSide].push({
idx,
role: 'to',
sortValue: getSortValue(toSide, e.p1),
});
const pairKey = [e.from, e.to].sort().join(':');
if (!pairBuckets.has(pairKey)) pairBuckets.set(pairKey, []);
pairBuckets.get(pairKey).push(idx);
edgeRoutes.set(idx, {
fromSide,
toSide,
fromSlot: 0,
toSlot: 0,
bundleOffset: 0,
});
});
nodePortBuckets.forEach((sides) => {
Object.keys(sides).forEach((side) => {
const list = sides[side];
list.sort((a, b) => a.sortValue - b.sortValue);
const center = (list.length - 1) / 2;
list.forEach((item, order) => {
const slotOffset = (order - center) * slotStep;
const route = edgeRoutes.get(item.idx);
if (!route) return;
if (item.role === 'from') route.fromSlot = slotOffset;
else route.toSlot = slotOffset;
});
});
});
pairBuckets.forEach((idxList) => {
idxList.sort((a, b) => {
const ea = edgesWithPing[a];
const eb = edgesWithPing[b];
return String(ea.tunnelType || '').localeCompare(String(eb.tunnelType || '')) || ea.edgeKey.localeCompare(eb.edgeKey);
});
const center = (idxList.length - 1) / 2;
idxList.forEach((edgeIdx, order) => {
const route = edgeRoutes.get(edgeIdx);
if (!route) return;
route.bundleOffset = (order - center) * 14;
});
});
return edgesWithPing.map((e, idx) => ({
...e,
route: edgeRoutes.get(idx),
}));
}, [edgesWithPing]);
// Вычисляем геометрию ребра: выход из "порта" узла + короткий stem (трезубец) + плавная дуга.
const computeCurvePoints = useCallback((e) => {
const { p1, p2 } = e;
const route = e.route || { fromSide: 'right', toSide: 'left', fromSlot: 0, toSlot: 0, bundleOffset: 0 };
const stem = 14;
const startAnchor = anchorPoint(p1, route.fromSide, route.fromSlot);
const endAnchor = anchorPoint(p2, route.toSide, route.toSlot);
const fromVec = sideVector(route.fromSide);
const toVec = sideVector(route.toSide);
const sx = startAnchor.x;
const sy = startAnchor.y;
const ex = endAnchor.x;
const ey = endAnchor.y;
const sOutX = sx + fromVec.x * stem;
const sOutY = sy + fromVec.y * stem;
const eOutX = ex + toVec.x * stem;
const eOutY = ey + toVec.y * stem;
const ddx = eOutX - sOutX;
const ddy = eOutY - sOutY;
const len = Math.hypot(ddx, ddy) || 1;
const baseOffset = Math.min(44, len / 2);
const bendOffset = baseOffset + (route.bundleOffset || 0);
const bendX = (-ddy / len) * bendOffset;
const bendY = (ddx / len) * bendOffset;
const cpx = (sOutX + eOutX) / 2 + bendX;
const cpy = (sOutY + eOutY) / 2 + bendY;
// Середина квадратичной кривой при t=0.5
const mx = 0.25 * sOutX + 0.5 * cpx + 0.25 * eOutX;
const my = 0.25 * sOutY + 0.5 * cpy + 0.25 * eOutY;
const perpLen = Math.hypot(-ddy, ddx) || 1;
const ux = (-ddy) / perpLen;
const uy = (ddx) / perpLen;
return {
sx,
sy,
ex,
ey,
sOutX,
sOutY,
eOutX,
eOutY,
cpx,
cpy,
mx,
my,
perpX: ux,
perpY: uy,
};
}, []);
const pathD = useCallback((e) => {
const { sx, sy, ex, ey, sOutX, sOutY, eOutX, eOutY, cpx, cpy } = computeCurvePoints(e);
return `M ${sx} ${sy} L ${sOutX} ${sOutY} Q ${cpx} ${cpy} ${eOutX} ${eOutY} L ${ex} ${ey}`;
}, [computeCurvePoints]);
/** Точка подписи на кривой с небольшим jitter и ограничением в пределах карты */
const labelPlacement = useCallback((e, level = 0) => {
const geom = computeCurvePoints(e);
const jitter = (hash01(e.edgeKey) - 0.5) * 0.24;
const t = clamp(0.42 + jitter, 0.26, 0.74);
const omt = 1 - t;
const xCurve = omt * omt * geom.sOutX + 2 * omt * t * geom.cpx + t * t * geom.eOutX;
const yCurve = omt * omt * geom.sOutY + 2 * omt * t * geom.cpy + t * t * geom.eOutY;
const baseOffset = 16;
const stepOffset = 18;
const x = xCurve + geom.perpX * (baseOffset + level * stepOffset);
const y = yCurve + geom.perpY * (baseOffset + level * stepOffset);
const margin = 22;
return {
x: clamp(x, margin, Math.max(margin, size.w - margin)),
y: clamp(y, margin, Math.max(margin, size.h - margin)),
};
}, [computeCurvePoints, size.w, size.h]);
const problematicNodes = useMemo(() => {
const byIp = new Map();
servers.forEach((s) => {
byIp.set(String(s.ip), {
ip: String(s.ip),
dns: s.dns || '',
type: String(s.type || '').toLowerCase(),
linkCount: 0,
staleCount: 0,
maxPingMs: null,
minSpeedMbps: null,
});
});
(connections || []).forEach((c) => {
const from = byIp.get(String(c.from));
const to = byIp.get(String(c.to));
if (from) from.linkCount += 1;
if (to) to.linkCount += 1;
});
routedEdges.forEach((e) => {
[e.from, e.to].forEach((nodeIp) => {
const node = byIp.get(String(nodeIp));
if (!node) return;
if (e.stale) node.staleCount += 1;
if (typeof e.pingMs === 'number') {
node.maxPingMs = node.maxPingMs == null ? e.pingMs : Math.max(node.maxPingMs, e.pingMs);
}
const down = e.speed?.tcpDownloadBps != null ? e.speed.tcpDownloadBps / 1_000_000 : null;
const up = e.speed?.tcpUploadBps != null ? e.speed.tcpUploadBps / 1_000_000 : null;
const edgeMin = down != null && up != null ? Math.min(down, up) : (down ?? up);
if (edgeMin != null && Number.isFinite(edgeMin)) {
node.minSpeedMbps = node.minSpeedMbps == null ? edgeMin : Math.min(node.minSpeedMbps, edgeMin);
}
});
});
return Array.from(byIp.values())
.map((n) => {
const issues = [];
let riskScore = 0;
if (n.maxPingMs != null) {
if (n.maxPingMs >= 120) {
issues.push('Очень высокий ping');
riskScore += 3;
} else if (n.maxPingMs >= 80) {
issues.push('Повышенный ping');
riskScore += 2;
}
}
if (n.minSpeedMbps != null) {
if (n.minSpeedMbps < 40) {
issues.push('Низкая скорость');
riskScore += 3;
} else if (n.minSpeedMbps < 80) {
issues.push('Скорость ниже нормы');
riskScore += 2;
}
}
if (n.staleCount > 0) {
issues.push('Есть устаревшие метрики');
riskScore += n.staleCount >= 2 ? 2 : 1;
}
if (n.linkCount >= 7) {
issues.push('Высокая связанность');
riskScore += 1;
}
return {
...n,
issues,
riskScore,
nodeLabel: n.dns ? String(n.dns).split('.')[0] : n.ip,
};
})
.filter((n) => n.issues.length > 0)
.sort((a, b) =>
b.riskScore - a.riskScore ||
(b.maxPingMs ?? -1) - (a.maxPingMs ?? -1) ||
(a.minSpeedMbps ?? Infinity) - (b.minSpeedMbps ?? Infinity)
);
}, [servers, connections, routedEdges]);
const criticalNodes = useMemo(
() => problematicNodes.filter((n) => n.riskScore >= 4).slice(0, 8),
[problematicNodes]
);
const warningNodes = useMemo(
() => problematicNodes.filter((n) => n.riskScore >= 1 && n.riskScore < 4).slice(0, 12),
[problematicNodes]
);
if (servers.length === 0) return null;
return (
<>
<div
ref={containerRef}
className="network-map-unifi"
style={{
width: '100%',
height: isFullscreen ? '100%' : '68vh',
minHeight: isFullscreen ? 400 : 620,
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;
const denseMode = routedEdges.length > 14;
return routedEdges.map((e, idx) => {
const dimFactor =
hoveredEdgeKey && hoveredEdgeKey !== e.edgeKey ? 0.16 : 1;
const strokeWidth = hoveredEdgeKey === e.edgeKey ? 3 : 1.8;
const edgeColor = getTunnelColor(e.tunnelType);
const isFocused =
hoveredEdgeKey === e.edgeKey || selectedEdge?.edgeKey === e.edgeKey;
const baseOpacity = denseMode ? 0.62 : 0.82;
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={baseOpacity * dimFactor}
filter="url(#edgeGlow)"
/>
{(() => {
const compactOnly = denseMode && !isFocused && !e.stale;
const labelText = compactOnly
? (e.compactEdgeLabel || e.edgeLabel)
: e.edgeLabel;
if (!labelText) return null;
const minDist = denseMode ? 52 : 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 = labelText.length * (compactOnly ? 5.9 : 6.2);
const iconWidth = isStale ? 14 : 0;
const minW = compactOnly ? 76 : LABEL_W;
const maxW = compactOnly ? 160 : 210;
const w = Math.max(minW, Math.min(textWidth + pad * 2 + iconWidth, maxW));
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={isFocused ? 'rgba(2,6,23,0.94)' : 'rgba(2,6,23,0.85)'}
stroke={isStale ? staleColor : edgeColor}
strokeWidth={isFocused ? 1.3 : 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={compactOnly ? 10.2 : 11}
fontWeight={600}
>
{labelText}
</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>
<div className="row g-3 mt-2">
<div className="col-12 col-xl-6">
<div
className="card"
style={{ background: '#0f172a', border: `1px solid ${UNIFI_CARD_BORDER}` }}
>
<div className="card-header">
<h3 className="card-title mb-0">Критичные узлы</h3>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0 table-sm">
<thead>
<tr>
<th scope="col">Узел</th>
<th scope="col">Тип</th>
<th scope="col">Ping max</th>
<th scope="col">Speed min</th>
<th scope="col">Связи</th>
<th scope="col">Проблемы</th>
</tr>
</thead>
<tbody>
{criticalNodes.length === 0 ? (
<tr>
<td colSpan={6} className="text-muted">Критичных узлов не найдено</td>
</tr>
) : (
criticalNodes.map((n) => (
<tr key={`critical-${n.ip}`}>
<td>
<div className="fw-semibold">{n.nodeLabel}</div>
<div className="text-muted small">{n.ip}</div>
</td>
<td>
<span className="badge bg-secondary-lt text-secondary">
{String(n.type || 'node').toUpperCase()}
</span>
</td>
<td>{n.maxPingMs != null ? `${n.maxPingMs} ms` : '—'}</td>
<td>{n.minSpeedMbps != null ? `${n.minSpeedMbps.toFixed(1)} Мбит/с` : '—'}</td>
<td>{n.linkCount}</td>
<td className="text-warning">{n.issues.slice(0, 2).join(', ')}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
<div className="col-12 col-xl-6">
<div
className="card"
style={{ background: '#0f172a', border: `1px solid ${UNIFI_CARD_BORDER}` }}
>
<div className="card-header">
<h3 className="card-title mb-0">Требуют внимания</h3>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0 table-sm table-striped">
<thead>
<tr>
<th scope="col">Узел</th>
<th scope="col">Ping max</th>
<th scope="col">Speed min</th>
<th scope="col">Stale</th>
<th scope="col">Проблемы</th>
</tr>
</thead>
<tbody>
{warningNodes.length === 0 ? (
<tr>
<td colSpan={5} className="text-muted">Узлов с предупреждениями не найдено</td>
</tr>
) : (
warningNodes.map((n) => (
<tr key={`warn-${n.ip}`}>
<td>
<div className="fw-semibold">{n.nodeLabel}</div>
<div className="text-muted small">{n.ip}</div>
</td>
<td>{n.maxPingMs != null ? `${n.maxPingMs} ms` : '—'}</td>
<td>{n.minSpeedMbps != null ? `${n.minSpeedMbps.toFixed(1)} Мбит/с` : '—'}</td>
<td>{n.staleCount}</td>
<td className="text-muted">{n.issues.slice(0, 2).join(', ')}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</>
);
}