Ver Fonte

feat: 修复网络拓扑匹配逻辑,使用双向LLDP对称匹配策略

Your Name há 4 semanas atrás
pai
commit
07adc3ac5c
4 ficheiros alterados com 163 adições e 51 exclusões
  1. 45 50
      internal/topology/builder.go
  2. 33 0
      web/css/style.css
  3. 7 1
      web/index.html
  4. 78 0
      web/js/app.js

+ 45 - 50
internal/topology/builder.go

@@ -157,49 +157,62 @@ func (b *Builder) Build() models.TopologyGraph {
 				}
 			}
 
-			// 策略3b: 通过MAC前缀匹配(改进:排除歧义前缀)
-			// 当精确MAC匹配失败时,尝试通过MAC前缀匹配
-			// 但如果同一前缀匹配到多台设备,则跳过(避免错误连接)
-			if targetIP == "" && neighbor.RemoteMAC != "" {
+			// 策略3b: MAC前缀匹配已禁用 - 同品牌设备MAC前缀相同,极易产生误匹配
+			// 如果需要连接,请使用精确MAC匹配或确保邻居设备在设备列表中
+			if false && targetIP == "" && neighbor.RemoteMAC != "" {
 				neighborMACPrefix := getMACPrefix(neighbor.RemoteMAC)
-				fmt.Printf("    Trying MAC prefix match: %s (prefix: %s)\n", neighbor.RemoteMAC, neighborMACPrefix)
+				fmt.Printf("    ⚠ MAC prefix match disabled (too unreliable): %s (prefix: %s)\n", neighbor.RemoteMAC, neighborMACPrefix)
+			}
 
-				// 先统计有多少台设备匹配此MAC前缀
-				type prefixMatch struct {
-					ip           string
-					matchingMACs int
-				}
-				var matches []prefixMatch
+			// 策略3c: LLDP对称性匹配(改进 - 需要双向验证)
+			// 设备A的接口X连接到设备B的接口Y
+			// 同时设备B的接口Y也应该连接到设备A的接口X
+			if targetIP == "" && neighbor.RemoteMAC != "" && neighbor.RemoteInterface != "" {
+				fmt.Printf("    Trying LLDP symmetric match: looking for device with interface %s\n", neighbor.RemoteInterface)
 
 				for _, d := range b.devices {
 					if d.IP == device.IP {
 						continue // 跳过自己
 					}
 
-					matchingMACs := 0
-					for _, mac := range d.MACAddresses {
-						if getMACPrefix(mac) == neighborMACPrefix {
-							matchingMACs++
+					// 检查设备d是否有这个接口
+					hasInterface := false
+					for _, iface := range d.Interfaces {
+						if iface.Name == neighbor.RemoteInterface {
+							hasInterface = true
+							break
 						}
 					}
 
-					if matchingMACs >= 3 {
-						matches = append(matches, prefixMatch{ip: d.IP, matchingMACs: matchingMACs})
+					if !hasInterface {
+						continue
+					}
+
+					// 双向验证:检查设备d的邻居信息中,这个接口是否连接回当前设备
+					hasReverseLink := false
+					for _, dNeighbor := range d.Neighbors {
+						if dNeighbor.RemoteInterface == neighbor.LocalInterface && dNeighbor.LocalInterface == neighbor.RemoteInterface {
+							hasReverseLink = true
+							break
+						}
 					}
-				}
 
-				// 只在唯一匹配时使用前缀匹配
-				if len(matches) == 1 && getSubnet(matches[0].ip) == getSubnet(device.IP) {
-					targetIP = matches[0].ip
-					matchMethod = fmt.Sprintf("MAC-prefix(%s)", neighborMACPrefix)
-					fmt.Printf("    ✓ Matched by MAC prefix: %s (device has %d MACs with prefix %s, same subnet) -> %s\n",
-						neighbor.RemoteMAC, matches[0].matchingMACs, neighborMACPrefix, targetIP)
-				} else if len(matches) > 1 {
-					fmt.Printf("    ✗ Skipping MAC prefix match: %d devices share prefix %s (ambiguous)\n", len(matches), neighborMACPrefix)
+					if hasReverseLink {
+						// 双向验证通过!
+						targetIP = d.IP
+						matchMethod = fmt.Sprintf("LLDP-symmetric(%s<->%s)", neighbor.LocalInterface, neighbor.RemoteInterface)
+						fmt.Printf("    ✓ Matched by LLDP symmetric (bidirectional): %s %s <-> %s %s -> %s\n",
+							device.IP, neighbor.LocalInterface, d.IP, neighbor.RemoteInterface, targetIP)
+						break
+					} else {
+						fmt.Printf("    ✗ Device %s has interface %s but no reverse link to %s %s\n",
+							d.IP, neighbor.RemoteInterface, device.IP, neighbor.LocalInterface)
+					}
 				}
 			}
 
 			// 策略4: 通过本地接口IP网段匹配(新增)
+			// 注意:此策略容易产生误匹配,仅在必要时使用
 			if targetIP == "" {
 				// 查找本地接口的IP地址
 				localInterfaceIP := ""
@@ -211,7 +224,7 @@ func (b *Builder) Build() models.TopologyGraph {
 				}
 
 				if localInterfaceIP != "" {
-					fmt.Printf("    Trying subnet match: local interface %s has IP %s\n",
+					fmt.Printf("    Trying subnet match (risky): local interface %s has IP %s\n",
 						neighbor.LocalInterface, localInterfaceIP)
 
 					// 计算本地接口的网段
@@ -227,7 +240,7 @@ func (b *Builder) Build() models.TopologyGraph {
 						if getSubnet(d.IP) == localSubnet {
 							targetIP = d.IP
 							matchMethod = "subnet(management IP)"
-							fmt.Printf("    ✓ Matched by subnet: %s in %s\n", d.IP, localSubnet)
+							fmt.Printf("    ⚠ Matched by subnet (risky): %s in %s\n", d.IP, localSubnet)
 							break
 						}
 
@@ -236,7 +249,7 @@ func (b *Builder) Build() models.TopologyGraph {
 							if iface.IP != "" && getSubnet(iface.IP) == localSubnet {
 								targetIP = d.IP
 								matchMethod = fmt.Sprintf("subnet(interface %s)", iface.Name)
-								fmt.Printf("    ✓ Matched by subnet: %s (%s) in %s\n",
+								fmt.Printf("    ⚠ Matched by subnet (risky): %s (%s) in %s\n",
 									d.IP, iface.Name, localSubnet)
 								break
 							}
@@ -246,27 +259,9 @@ func (b *Builder) Build() models.TopologyGraph {
 						}
 					}
 				} else {
-					// 策略4b: 本地接口没有IP,尝试使用设备管理IP进行子网匹配(新增)
-					// 注意:只有当该网段只有2台设备时才使用此策略(点对点连接)
-					fmt.Printf("    Trying subnet match with management IP: %s\n", device.IP)
-					localSubnet := getSubnet(device.IP)
-
-					// 统计在该网段的设备数量
-					var devicesInSubnet []string
-					for _, d := range b.devices {
-						if d.IP != device.IP && getSubnet(d.IP) == localSubnet {
-							devicesInSubnet = append(devicesInSubnet, d.IP)
-						}
-					}
-
-					// 只有当网段中恰好有1台其他设备时才匹配(点对点)
-					if len(devicesInSubnet) == 1 {
-						targetIP = devicesInSubnet[0]
-						matchMethod = "subnet(management IP both sides)"
-						fmt.Printf("    ✓ Matched by management subnet: %s in %s (only device in subnet)\n", targetIP, localSubnet)
-					} else if len(devicesInSubnet) > 1 {
-						fmt.Printf("    ✗ Skipping subnet match: %d devices in subnet %s (ambiguous)\n", len(devicesInSubnet)+1, localSubnet)
-					}
+					// 策略4b: 本地接口没有IP,尝试使用设备管理IP进行子网匹配(高风险策略 - 已禁用)
+					// 此策略容易产生错误连接,暂时禁用
+					fmt.Printf("    ✗ Skipping subnet match with management IP (disabled - too risky)\n")
 				}
 			}
 

+ 33 - 0
web/css/style.css

@@ -100,6 +100,8 @@ header h1 {
 
 .sidebar {
     width: 300px;
+    min-width: 200px;
+    max-width: 600px;
     background: white;
     padding: 20px;
     overflow-y: auto;
@@ -190,6 +192,7 @@ header h1 {
 .content {
     flex: 1;
     position: relative;
+    min-width: 400px;
 }
 
 #cy {
@@ -200,17 +203,47 @@ header h1 {
 
 .detail-panel {
     width: 600px;
+    min-width: 300px;
+    max-width: 1000px;
     background: white;
     padding: 20px;
     overflow-y: auto;
     box-shadow: -2px 0 5px rgba(0,0,0,0.1);
     display: none;
+    position: relative;
 }
 
 .detail-panel.active {
     display: block;
 }
 
+/* 拖拽手柄样式 */
+.resizer {
+    width: 5px;
+    cursor: col-resize;
+    background: #e0e0e0;
+    position: relative;
+    z-index: 10;
+    transition: background 0.2s;
+}
+
+.resizer:hover,
+.resizer.resizing {
+    background: #667eea;
+}
+
+.resizer::after {
+    content: '';
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    width: 3px;
+    height: 30px;
+    background: rgba(0, 0, 0, 0.2);
+    border-radius: 2px;
+}
+
 .detail-panel h3 {
     margin-bottom: 15px;
     color: #667eea;

+ 7 - 1
web/index.html

@@ -29,7 +29,7 @@
 
         <div class="main-content">
             <!-- 侧边栏 -->
-            <aside class="sidebar">
+            <aside class="sidebar" id="sidebar">
                 <div class="panel">
                     <h3>扫描配置</h3>
                     <form id="scan-form">
@@ -69,11 +69,17 @@
                 </div>
             </aside>
 
+            <!-- 左侧拖拽手柄 -->
+            <div class="resizer" id="resizer-left"></div>
+
             <!-- 主内容区 -->
             <main class="content">
                 <div id="cy"></div>
             </main>
 
+            <!-- 右侧拖拽手柄 -->
+            <div class="resizer" id="resizer-right"></div>
+
             <!-- 详情面板 -->
             <aside class="detail-panel" id="detail-panel">
                 <h3>设备详情</h3>

+ 78 - 0
web/js/app.js

@@ -908,3 +908,81 @@ async function saveEditDevice(event) {
         alert('修改失败: ' + error.message);
     }
 }
+
+// 面板拖拽调整大小功能
+(function() {
+    // 左侧面板拖拽
+    var resizerLeft = document.getElementById('resizer-left');
+    var sidebar = document.getElementById('sidebar');
+    
+    if (resizerLeft && sidebar) {
+        var isResizingLeft = false;
+        
+        resizerLeft.addEventListener('mousedown', function(e) {
+            isResizingLeft = true;
+            resizerLeft.classList.add('resizing');
+            document.body.style.cursor = 'col-resize';
+            document.body.style.userSelect = 'none';
+            e.preventDefault();
+        });
+        
+        document.addEventListener('mousemove', function(e) {
+            if (!isResizingLeft) return;
+            
+            var newWidth = e.clientX;
+            var minWidth = 200;
+            var maxWidth = 600;
+            
+            if (newWidth >= minWidth && newWidth <= maxWidth) {
+                sidebar.style.width = newWidth + 'px';
+            }
+        });
+        
+        document.addEventListener('mouseup', function() {
+            if (isResizingLeft) {
+                isResizingLeft = false;
+                resizerLeft.classList.remove('resizing');
+                document.body.style.cursor = '';
+                document.body.style.userSelect = '';
+            }
+        });
+    }
+    
+    // 右侧面板拖拽
+    var resizerRight = document.getElementById('resizer-right');
+    var detailPanel = document.getElementById('detail-panel');
+    
+    if (resizerRight && detailPanel) {
+        var isResizingRight = false;
+        
+        resizerRight.addEventListener('mousedown', function(e) {
+            isResizingRight = true;
+            resizerRight.classList.add('resizing');
+            document.body.style.cursor = 'col-resize';
+            document.body.style.userSelect = 'none';
+            e.preventDefault();
+        });
+        
+        document.addEventListener('mousemove', function(e) {
+            if (!isResizingRight) return;
+            
+            var containerWidth = document.querySelector('.main-content').offsetWidth;
+            var newWidth = containerWidth - e.clientX;
+            var minWidth = 300;
+            var maxWidth = 1000;
+            
+            if (newWidth >= minWidth && newWidth <= maxWidth) {
+                detailPanel.style.width = newWidth + 'px';
+            }
+        });
+        
+        document.addEventListener('mouseup', function() {
+            if (isResizingRight) {
+                isResizingRight = false;
+                resizerRight.classList.remove('resizing');
+                document.body.style.cursor = '';
+                document.body.style.userSelect = '';
+            }
+        });
+    }
+})();