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 ( <>
{/* Рёбра: сглаженные линии с цветовой дифференциацией туннеля */} {(() => { 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 ( setHoveredEdgeKey(e.edgeKey)} onMouseLeave={() => setHoveredEdgeKey((prev) => (prev === e.edgeKey ? null : prev)) } onClick={() => setSelectedEdge(e)} style={{ cursor: 'pointer' }} > {(() => { 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 ( {isStale && ( )} {labelText} ); })()} ); }); })()} {/* Узлы: карточки серверов в стиле 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 ( onNodeMouseDown(e, s.ip)} style={{ cursor: isDragging ? 'grabbing' : 'grab' }} > {/* Флаг страны в левом верхнем углу карточки (картинка, т.к. эмодзи в SVG часто отображаются как буквы) */} {s.country && (() => { const cc = String(s.country).trim().toLowerCase(); if (cc.length !== 2) return null; const flagW = 22; const flagH = 16; return ( {s.country.toUpperCase()} ); })()} {/* Иконка сервера в стиле UNIFI: прямоугольник с «портами» */} {label.length > 14 ? `${label.slice(0, 13)}…` : label} {s.ip} {typeMeta.label} ); })} {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 (
setSelectedEdge(null)} >
e.stopPropagation()} >
Детали соединения
От
{fromServer?.dns || fromServer?.ip || selectedEdge.from}{' '} ({fromServer?.ip || '—'}){' '} {String(fromServer?.type || '').toUpperCase() || 'SERVER'}
К
{toServer?.dns || toServer?.ip || selectedEdge.to}{' '} ({toServer?.ip || '—'}){' '} {String(toServer?.type || '').toUpperCase() || 'SERVER'}
Туннель
{selectedEdge.tunnelType || 'TUNNEL'} {selectedEdge.raw?.internalFromTo && ( {selectedEdge.raw.internalToFrom} ⇄ {selectedEdge.raw.internalFromTo} )}
Пинг {pingStale && ( Устаревшие данные )}
{typeof pingMs === 'number' ? `${pingMs} ms` : 'нет данных'}
{speed && (speed.tcpDownloadBps != null || speed.tcpUploadBps != null) && (
Скорость по туннелю {speed.cached && ( кеш )} {speedStale && ( Устаревшие данные )}
↓ Download {formatMbps(speed.tcpDownloadBps)}
↑ Upload {formatMbps(speed.tcpUploadBps)}
{speed.durationSeconds != null && (
Замер за {speed.durationSeconds} сек
)}
)}
); })()}

Критичные узлы

{criticalNodes.length === 0 ? ( ) : ( criticalNodes.map((n) => ( )) )}
Узел Тип Ping max Speed min Связи Проблемы
Критичных узлов не найдено
{n.nodeLabel}
{n.ip}
{String(n.type || 'node').toUpperCase()} {n.maxPingMs != null ? `${n.maxPingMs} ms` : '—'} {n.minSpeedMbps != null ? `${n.minSpeedMbps.toFixed(1)} Мбит/с` : '—'} {n.linkCount} {n.issues.slice(0, 2).join(', ')}

Требуют внимания

{warningNodes.length === 0 ? ( ) : ( warningNodes.map((n) => ( )) )}
Узел Ping max Speed min Stale Проблемы
Узлов с предупреждениями не найдено
{n.nodeLabel}
{n.ip}
{n.maxPingMs != null ? `${n.maxPingMs} ms` : '—'} {n.minSpeedMbps != null ? `${n.minSpeedMbps.toFixed(1)} Мбит/с` : '—'} {n.staleCount} {n.issues.slice(0, 2).join(', ')}
); }