Files
router-lists-ui/frontend/src/NetworkMapUnifi.jsx
T
denozord 7029fa2897
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m38s
fix
2026-02-24 23:03:27 +07:00

793 lines
30 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;
}
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>
);
}