Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m28s
1142 lines
42 KiB
React
1142 lines
42 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;
|
||
}
|
||
|
||
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>
|
||
</>
|
||
);
|
||
}
|