/** * 网络拓扑可视化模块 * 基于D3.js实现网络拓扑图的可视化和交互 */ class TopologyVisualization { constructor() { // 初始化变量 this.svg = null; this.simulation = null; this.nodes = []; this.links = []; this.selectedNode = null; this.selectedLink = null; this.transform = d3.zoomIdentity; this.width = 0; this.height = 0; // UI元素 this.elements = {}; // 厂商颜色映射 this.vendorColors = { cisco: '#1e40af', huawei: '#dc2626', h3c: '#059669', juniper: '#7c3aed', other: '#6b7280' }; // 初始化 this.init(); } /** * 初始化拓扑可视化 */ init() { console.log('初始化网络拓扑可视化模块'); // 缓存DOM元素 this.cacheElements(); // 初始化SVG this.initSVG(); // 绑定事件 this.bindEvents(); // 加载初始拓扑数据 this.loadTopology(); } /** * 缓存DOM元素 */ cacheElements() { this.elements = { // 主要容器 topologyContent: document.getElementById('topology-content'), topologyCanvas: document.getElementById('topology-canvas'), topologySvg: document.getElementById('topology-svg'), topologyLoading: document.getElementById('topology-loading'), topologyEmpty: document.getElementById('topology-empty'), // 工具栏按钮 discoverBtn: document.getElementById('discover-topology-btn'), refreshBtn: document.getElementById('refresh-topology-btn'), clearBtn: document.getElementById('clear-topology-btn'), fitBtn: document.getElementById('fit-topology-btn'), zoomInBtn: document.getElementById('zoom-in-btn'), zoomOutBtn: document.getElementById('zoom-out-btn'), centerBtn: document.getElementById('center-topology-btn'), // 过滤器 vendorFilter: document.getElementById('vendor-filter'), deviceTypeFilter: document.getElementById('device-type-filter'), applyFilterBtn: document.getElementById('apply-filter-btn'), // 统计元素 statNodes: document.getElementById('stat-nodes'), statLinks: document.getElementById('stat-links'), statProtocols: document.getElementById('stat-protocols'), statVendors: document.getElementById('stat-vendors'), // 详情面板 nodeDetails: document.getElementById('node-details'), linkDetails: document.getElementById('link-details'), // 操作按钮 connectBtn: document.getElementById('connect-to-device-btn'), pingBtn: document.getElementById('ping-device-btn'), discoverNeighborsBtn: document.getElementById('discover-neighbors-btn'), propertiesBtn: document.getElementById('device-properties-btn'), // 对话框 discoverModal: document.getElementById('topology-discover-modal'), discoverForm: document.getElementById('topology-discover-form'), seedDevicesList: document.getElementById('seed-devices-list'), discoveryProgress: document.getElementById('discovery-progress'), discoveryStatus: document.getElementById('discovery-status'), discoveryProgressBar: document.getElementById('discovery-progress-bar'), // 其他 gotoDevicesBtn: document.getElementById('goto-devices-btn') }; } /** * 初始化SVG */ initSVG() { const container = this.elements.topologyCanvas; this.width = container.clientWidth; this.height = container.clientHeight; this.svg = d3.select('#topology-svg') .attr('width', this.width) .attr('height', this.height); // 创建主要的图形组 this.g = this.svg.select('#topology-graph'); this.linkGroup = this.svg.select('#links-group'); this.nodeGroup = this.svg.select('#nodes-group'); this.labelGroup = this.svg.select('#labels-group'); // 获取或创建defs元素 let defs = this.svg.select('defs'); if (defs.empty()) { defs = this.svg.append('defs'); } // 添加渐变定义 this.createGradients(defs); // 添加阴影滤镜 this.createShadowFilter(defs); // 设置缩放行为 const zoom = d3.zoom() .scaleExtent([0.1, 4]) .on('zoom', (event) => { this.transform = event.transform; this.g.attr('transform', this.transform); }); this.svg.call(zoom); // 初始化力仿真 this.simulation = d3.forceSimulation() .force('link', d3.forceLink().id(d => d.id).distance(150)) .force('charge', d3.forceManyBody().strength(-300)) .force('center', d3.forceCenter(this.width / 2, this.height / 2)) .force('collision', d3.forceCollide().radius(30)); // 从25增加到30,适应更大的图标 } /** * 创建渐变定义 */ createGradients(defs) { const vendors = ['cisco', 'huawei', 'h3c', 'juniper', 'other']; vendors.forEach(vendor => { const gradient = defs.append('radialGradient') .attr('id', `gradient-${vendor}`) .attr('cx', '30%') .attr('cy', '30%') .attr('r', '70%'); const baseColor = this.vendorColors[vendor]; gradient.append('stop') .attr('offset', '0%') .attr('stop-color', this.lightenColor(baseColor, 0.3)); gradient.append('stop') .attr('offset', '100%') .attr('stop-color', baseColor); }); } /** * 创建阴影滤镜 */ createShadowFilter(defs) { const filter = defs.append('filter') .attr('id', 'drop-shadow') .attr('x', '-50%') .attr('y', '-50%') .attr('width', '200%') .attr('height', '200%'); filter.append('feDropShadow') .attr('dx', 2) .attr('dy', 2) .attr('stdDeviation', 3) .attr('flood-color', '#000') .attr('flood-opacity', 0.3); } /** * 调亮颜色 */ lightenColor(color, percent) { const num = parseInt(color.replace("#",""), 16); const amt = Math.round(2.55 * percent * 100); const R = (num >> 16) + amt; const G = (num >> 8 & 0x00FF) + amt; const B = (num & 0x0000FF) + amt; return "#" + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 + (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 + (B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1); } /** * 绑定事件 */ bindEvents() { // 工具栏事件 this.elements.discoverBtn?.addEventListener('click', () => this.showDiscoverDialog()); this.elements.refreshBtn?.addEventListener('click', () => this.loadTopology()); this.elements.clearBtn?.addEventListener('click', () => this.clearTopology()); this.elements.fitBtn?.addEventListener('click', () => this.fitToView()); this.elements.zoomInBtn?.addEventListener('click', () => this.zoomIn()); this.elements.zoomOutBtn?.addEventListener('click', () => this.zoomOut()); this.elements.centerBtn?.addEventListener('click', () => this.centerView()); this.elements.applyFilterBtn?.addEventListener('click', () => this.applyFilters()); // 操作按钮事件 this.elements.connectBtn?.addEventListener('click', () => this.connectToDevice()); this.elements.pingBtn?.addEventListener('click', () => this.pingDevice()); this.elements.discoverNeighborsBtn?.addEventListener('click', () => this.discoverNeighbors()); this.elements.propertiesBtn?.addEventListener('click', () => this.showDeviceProperties()); // 其他事件 this.elements.gotoDevicesBtn?.addEventListener('click', () => this.gotoDevicesPage()); // 发现对话框事件 this.elements.discoverForm?.addEventListener('submit', (e) => this.handleDiscoverSubmit(e)); // 窗口大小改变事件 window.addEventListener('resize', () => this.handleResize()); // SVG点击事件(取消选择和隐藏菜单) this.svg?.on('click', (event) => { if (event.target === this.svg.node()) { this.clearSelection(); this.hideContextMenu(); } }); // 全局点击事件(隐藏上下文菜单) document.addEventListener('click', (event) => { // 如果点击的不是菜单内容,则隐藏菜单 const menu = document.getElementById('topology-context-menu'); if (menu && !menu.contains(event.target)) { this.hideContextMenu(); } }); // 全局ESC键事件(隐藏上下文菜单) document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { this.hideContextMenu(); } }); } /** * 加载拓扑数据 */ async loadTopology() { try { this.showLoading(true); const response = await fetch('/api/topology'); const data = await response.json(); if (data.success && data.topology) { this.updateTopology(data.topology); this.showEmpty(false); } else { this.showEmpty(true); } } catch (error) { console.error('加载拓扑数据失败:', error); this.showEmpty(true); } finally { this.showLoading(false); } } /** * 更新拓扑数据 */ updateTopology(topology) { // 转换节点数据 this.nodes = Object.values(topology.nodes || {}).map(node => ({ id: node.device_id, name: node.name, ip: node.ip_address, vendor: node.vendor || 'other', deviceType: node.device_type || 'other', status: node.status || 'unknown', interfaces: node.interfaces || [], interface_count: node.interface_count || (node.interfaces ? node.interfaces.length : 0), // 添加接口数字段 // D3需要的位置信息 x: Math.random() * this.width, y: Math.random() * this.height })); // 转换链路数据 this.links = topology.links?.map(link => ({ id: `${link.source_device_id}-${link.target_device_id}`, source: link.source_device_id, target: link.target_device_id, sourceInterface: link.source_interface, targetInterface: link.target_interface, protocol: link.protocol || 'unknown' })) || []; // 更新可视化 this.renderTopology(); // 更新统计信息 this.updateStatistics(); } /** * 渲染拓扑图 */ renderTopology() { // 更新仿真数据 this.simulation.nodes(this.nodes); this.simulation.force('link').links(this.links); // 渲染链路 this.renderLinks(); // 渲染节点 this.renderNodes(); // 渲染标签 this.renderLabels(); // 重启仿真 this.simulation.alpha(1).restart(); // 显示SVG并隐藏空状态 this.elements.topologySvg.style.display = 'block'; // 根据节点数量决定是否显示空状态 if (this.nodes.length > 0) { this.showEmpty(false); } else { this.showEmpty(true); } } /** * 渲染链路 */ renderLinks() { const links = this.linkGroup.selectAll('.topology-link') .data(this.links, d => d.id); links.exit().remove(); const linkEnter = links.enter() .append('line') .attr('class', d => `topology-link ${d.protocol}`) .attr('stroke-width', 2) .on('click', (event, d) => this.selectLink(event, d)); const linkUpdate = linkEnter.merge(links); // 更新链路位置(在仿真tick中处理) this.simulation.on('tick', () => { linkUpdate .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); this.updateNodePositions(); this.updateLabelPositions(); }); } /** * 渲染节点 */ renderNodes() { const nodes = this.nodeGroup.selectAll('.topology-node-group') .data(this.nodes, d => d.id); nodes.exit().remove(); // 创建节点组 const nodeEnter = nodes.enter() .append('g') .attr('class', d => `topology-node-group ${d.vendor}`) .call(this.createDragBehavior()); // 绑定点击和双击事件 this.bindNodeEvents(nodeEnter); // 添加主节点圆圈(修改为完全透明) nodeEnter.append('circle') .attr('class', 'node-main-circle') .attr('r', 18) .attr('stroke', 'transparent') // 边框透明 .attr('stroke-width', 0) // 边框宽度为0 .style('fill', 'transparent') // 填充透明 .style('filter', 'none'); // 移除阴影效果 // 添加设备类型SVG图标(进一步增大尺寸) const iconGroup = nodeEnter.append('g') .attr('class', 'node-icon-group') .attr('transform', 'translate(-20, -20)'); // 调整位置以适应更大的图标 // 根据设备类型添加不同的SVG图标 iconGroup.each(function(d) { const group = d3.select(this); const iconSvg = window.topologyVisualization.createDeviceIcon(d.deviceType, true); // 传递黑色参数 group.node().innerHTML = iconSvg; }); // 添加状态指示器 nodeEnter.append('circle') .attr('class', 'node-status-indicator') .attr('r', 5) .attr('cx', 18) // 调整位置以适应更大的图标 .attr('cy', -18) .attr('stroke', '#fff') .attr('stroke-width', 2) .style('fill', d => this.getStatusColor(d.status)); // 添加厂商徽章 nodeEnter.append('circle') .attr('class', 'node-vendor-badge') .attr('r', 6) .attr('cx', -18) // 调整位置以适应更大的图标 .attr('cy', -18) .attr('stroke', '#fff') .attr('stroke-width', 2) .style('fill', d => this.vendorColors[d.vendor] || this.vendorColors.other); // 添加厂商徽章文字 nodeEnter.append('text') .attr('class', 'node-vendor-text') .attr('x', -18) // 调整位置以适应更大的图标 .attr('y', -15) .attr('text-anchor', 'middle') .style('font-size', '7px') .style('font-weight', 'bold') .style('fill', '#fff') .style('pointer-events', 'none') .text(d => this.getVendorBadgeText(d.vendor)); // 更新现有节点 const nodeUpdate = nodeEnter.merge(nodes); // 更新事件绑定 this.bindNodeEvents(nodeUpdate); // 更新主圆圈样式 nodeUpdate.select('.node-main-circle') .attr('r', 18) .style('fill', 'transparent'); // 更新状态指示器颜色 nodeUpdate.select('.node-status-indicator') .style('fill', d => this.getStatusColor(d.status)); // 更新图标 nodeUpdate.select('.node-icon-group') .each(function(d) { const group = d3.select(this); const iconSvg = window.topologyVisualization.createDeviceIcon(d.deviceType, true); group.node().innerHTML = iconSvg; }); // 添加选择效果 nodeUpdate.classed('selected', d => d === this.selectedNode); } /** * 绑定节点事件(单击选择,双击菜单) */ bindNodeEvents(selection) { let clickTimeout = null; selection .on('click', (event, d) => { event.stopPropagation(); // 清除之前的延迟 if (clickTimeout) { clearTimeout(clickTimeout); clickTimeout = null; } // 延迟执行单击,以区分双击 clickTimeout = setTimeout(() => { this.selectNode(event, d); clickTimeout = null; }, 250); }) .on('dblclick', (event, d) => { event.stopPropagation(); // 取消单击延迟 if (clickTimeout) { clearTimeout(clickTimeout); clickTimeout = null; } // 先选择节点 this.selectNode(event, d); // 显示上下文菜单 this.showContextMenu(event, d); }); } /** * 显示上下文菜单 */ showContextMenu(event, node) { // 隐藏已存在的菜单 this.hideContextMenu(); // 创建菜单容器 const menu = document.createElement('div'); menu.className = 'topology-context-menu'; menu.id = 'topology-context-menu'; // 菜单项配置 const menuItems = [ { icon: 'fas fa-terminal', text: '连接终端', action: () => this.connectToDevice(), disabled: false }, { icon: 'fas fa-satellite-dish', text: 'Ping测试', action: () => this.pingDevice(), disabled: false }, { icon: 'fas fa-search', text: '发现邻居', action: () => this.discoverNeighbors(), disabled: false }, { type: 'divider' }, { icon: 'fas fa-cog', text: '设备属性', action: () => this.showDeviceProperties(), disabled: false }, { icon: 'fas fa-copy', text: '复制IP地址', action: () => this.copyIPAddress(node), disabled: false }, { type: 'divider' }, { icon: 'fas fa-expand-arrows-alt', text: '居中到此设备', action: () => this.centerToNode(node), disabled: false } ]; // 创建菜单项 menuItems.forEach(item => { if (item.type === 'divider') { const divider = document.createElement('div'); divider.className = 'context-menu-divider'; menu.appendChild(divider); } else { const menuItem = document.createElement('div'); menuItem.className = `context-menu-item ${item.disabled ? 'disabled' : ''}`; menuItem.innerHTML = ` ${item.text} `; if (!item.disabled) { menuItem.addEventListener('click', (e) => { e.stopPropagation(); item.action(); this.hideContextMenu(); }); } menu.appendChild(menuItem); } }); // 添加到页面 document.body.appendChild(menu); // 计算位置(确保菜单不超出视窗) const rect = this.elements.topologyCanvas.getBoundingClientRect(); const menuRect = menu.getBoundingClientRect(); let x = event.clientX; let y = event.clientY; // 防止菜单超出右边界 if (x + menuRect.width > window.innerWidth) { x = window.innerWidth - menuRect.width - 10; } // 防止菜单超出下边界 if (y + menuRect.height > window.innerHeight) { y = window.innerHeight - menuRect.height - 10; } // 设置位置 menu.style.left = `${x}px`; menu.style.top = `${y}px`; // 添加动画效果 menu.style.opacity = '0'; menu.style.transform = 'scale(0.8)'; requestAnimationFrame(() => { menu.style.opacity = '1'; menu.style.transform = 'scale(1)'; }); // 存储当前菜单节点 this.contextMenuNode = node; } /** * 隐藏上下文菜单 */ hideContextMenu() { const existingMenu = document.getElementById('topology-context-menu'); if (existingMenu) { existingMenu.remove(); } this.contextMenuNode = null; } /** * 复制IP地址到剪贴板 */ async copyIPAddress(node) { try { await navigator.clipboard.writeText(node.ip); this.showNotification(`IP地址已复制: ${node.ip}`, 'success'); } catch (error) { console.error('复制失败:', error); this.showNotification('复制失败,请手动复制', 'error'); } } /** * 居中到指定节点 */ centerToNode(node) { if (!node) return; const scale = this.transform.k || 1; const translateX = this.width / 2 - node.x * scale; const translateY = this.height / 2 - node.y * scale; this.svg.transition() .duration(750) .call(d3.zoom().transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale)); } /** * 显示通知消息 */ showNotification(message, type = 'info') { // 创建通知元素 const notification = document.createElement('div'); notification.className = `topology-notification ${type}`; notification.textContent = message; // 添加到页面 document.body.appendChild(notification); // 设置初始样式 notification.style.position = 'fixed'; notification.style.top = '20px'; notification.style.right = '20px'; notification.style.zIndex = '10000'; notification.style.opacity = '0'; notification.style.transform = 'translateX(100%)'; notification.style.transition = 'all 0.3s ease'; // 显示动画 requestAnimationFrame(() => { notification.style.opacity = '1'; notification.style.transform = 'translateX(0)'; }); // 自动隐藏 setTimeout(() => { notification.style.opacity = '0'; notification.style.transform = 'translateX(100%)'; setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification); } }, 300); }, 3000); } /** * 渲染标签 */ renderLabels() { const labels = this.labelGroup.selectAll('.node-label') .data(this.nodes, d => d.id); labels.exit().remove(); const labelEnter = labels.enter() .append('text') .attr('class', 'node-label') .attr('dy', 35) // 调整标签位置,从25改回35 .text(d => d.name); const labelUpdate = labelEnter.merge(labels); } /** * 更新节点位置 - 优化版,支持拖拽时的实时更新 */ updateNodePositions() { this.nodeGroup.selectAll('.topology-node-group') .attr('transform', d => { // 优先使用固定位置(拖拽时设置的fx, fy),否则使用模拟位置 const x = d.fx !== undefined ? d.fx : d.x; const y = d.fy !== undefined ? d.fy : d.y; return `translate(${x},${y})`; }); } /** * 更新标签位置 */ updateLabelPositions() { this.labelGroup.selectAll('.node-label') .attr('x', d => d.x) .attr('y', d => d.y); } /** * 创建拖拽行为 - 优化版,只移动当前设备,其他设备保持固定 */ createDragBehavior() { return d3.drag() .on('start', (event, d) => { // 固定所有其他节点 this.nodes.forEach(node => { if (node !== d) { node.fx = node.x; node.fy = node.y; } }); // 设置当前节点为拖拽状态 d.fx = d.x; d.fy = d.y; // 提升被拖拽节点的层级 d3.select(event.sourceEvent.currentTarget.parentNode) .raise() .classed('dragging', true); // 暂停力模拟或设置很低的alpha值 this.simulation.alphaTarget(0.01).restart(); }) .on('drag', (event, d) => { // 实时更新当前节点位置 d.fx = event.x; d.fy = event.y; // 立即更新节点位置 const nodeGroup = d3.select(event.sourceEvent.currentTarget.parentNode); nodeGroup.attr('transform', `translate(${d.fx},${d.fy})`); // 立即更新相关链路 this.linkGroup.selectAll('.topology-link') .filter(link => link.source === d || link.target === d) .attr('x1', link => link.source.fx || link.source.x) .attr('y1', link => link.source.fy || link.source.y) .attr('x2', link => link.target.fx || link.target.x) .attr('y2', link => link.target.fy || link.target.y); // 立即更新标签位置 this.labelGroup.selectAll('.node-label') .filter(node => node === d) .attr('x', d.fx) .attr('y', d.fy + 35); // 调整标签位置,从25改回35 }) .on('end', (event, d) => { // 拖拽结束,停止力模拟 this.simulation.alphaTarget(0); // 移除拖拽状态 d3.select(event.sourceEvent.currentTarget.parentNode) .classed('dragging', false); // 保持当前节点在拖拽结束位置(固定位置) // 保持其他节点的固定位置不变 // 注意:不释放fx, fy,这样所有节点都保持在固定位置 }); } /** * 选择节点 */ selectNode(event, node) { event.stopPropagation(); // 清除之前的选择 this.clearSelection(); // 设置新选择 this.selectedNode = node; this.selectedLink = null; // 更新UI this.updateNodeSelection(); this.showNodeDetails(node); this.updateActionButtons(); } /** * 选择链路 */ selectLink(event, link) { event.stopPropagation(); // 清除之前的选择 this.clearSelection(); // 设置新选择 this.selectedLink = link; this.selectedNode = null; // 更新UI this.updateLinkSelection(); this.showLinkDetails(link); this.updateActionButtons(); } /** * 清除选择 */ clearSelection() { this.selectedNode = null; this.selectedLink = null; // 更新UI this.updateNodeSelection(); this.updateLinkSelection(); this.showDefaultDetails(); this.updateActionButtons(); } /** * 更新节点选择状态 */ updateNodeSelection() { this.nodeGroup.selectAll('.topology-node-group') .classed('selected', d => d === this.selectedNode); this.labelGroup.selectAll('.node-label') .classed('selected', d => d === this.selectedNode); } /** * 更新链路选择状态 */ updateLinkSelection() { this.linkGroup.selectAll('.topology-link') .classed('selected', d => d === this.selectedLink); } /** * 显示节点详情 */ showNodeDetails(node) { // 获取接口数,优先级:直接接口数据 > 连接数推测 > 显示未知 let interfaceCount; if (node.interfaces && Array.isArray(node.interfaces) && node.interfaces.length > 0) { // 有实际接口数据 interfaceCount = node.interfaces.length; } else if (node.interface_count && node.interface_count > 0) { // 有接口数字段 interfaceCount = node.interface_count; } else { // 尝试从连接的链路数推测 const connectedLinks = this.links.filter(link => (link.source.id || link.source) === node.id || (link.target.id || link.target) === node.id ); if (connectedLinks.length > 0) { interfaceCount = `${connectedLinks.length}+ (推测)`; } else { interfaceCount = '未知'; } } const detailsHtml = `
没有可用的设备。请先在设备管理页面添加设备。