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

This commit is contained in:
Your Name
2026-04-27 00:02:09 +08:00
parent 606e29a53c
commit 07adc3ac5c
4 changed files with 163 additions and 51 deletions
+44 -49
View File
@@ -157,49 +157,62 @@ func (b *Builder) Build() models.TopologyGraph {
} }
} }
// 策略3b: 通过MAC前缀匹配(改进:排除歧义前缀) // 策略3b: MAC前缀匹配已禁用 - 同品牌设备MAC前缀相同,极易产生误匹配
// 当精确MAC匹配失败时,尝试通过MAC前缀匹配 // 如果需要连接,请使用精确MAC匹配或确保邻居设备在设备列表中
// 但如果同一前缀匹配到多台设备,则跳过(避免错误连接) if false && targetIP == "" && neighbor.RemoteMAC != "" {
if targetIP == "" && neighbor.RemoteMAC != "" {
neighborMACPrefix := getMACPrefix(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 { for _, d := range b.devices {
if d.IP == device.IP { if d.IP == device.IP {
continue // 跳过自己 continue // 跳过自己
} }
matchingMACs := 0 // 检查设备d是否有这个接口
for _, mac := range d.MACAddresses { hasInterface := false
if getMACPrefix(mac) == neighborMACPrefix { for _, iface := range d.Interfaces {
matchingMACs++ if iface.Name == neighbor.RemoteInterface {
hasInterface = true
break
} }
} }
if matchingMACs >= 3 { if !hasInterface {
matches = append(matches, prefixMatch{ip: d.IP, matchingMACs: matchingMACs}) continue
}
// 双向验证:检查设备d的邻居信息中,这个接口是否连接回当前设备
hasReverseLink := false
for _, dNeighbor := range d.Neighbors {
if dNeighbor.RemoteInterface == neighbor.LocalInterface && dNeighbor.LocalInterface == neighbor.RemoteInterface {
hasReverseLink = true
break
} }
} }
// 只在唯一匹配时使用前缀匹配 if hasReverseLink {
if len(matches) == 1 && getSubnet(matches[0].ip) == getSubnet(device.IP) { // 双向验证通过!
targetIP = matches[0].ip targetIP = d.IP
matchMethod = fmt.Sprintf("MAC-prefix(%s)", neighborMACPrefix) matchMethod = fmt.Sprintf("LLDP-symmetric(%s<->%s)", neighbor.LocalInterface, neighbor.RemoteInterface)
fmt.Printf(" ✓ Matched by MAC prefix: %s (device has %d MACs with prefix %s, same subnet) -> %s\n", fmt.Printf(" ✓ Matched by LLDP symmetric (bidirectional): %s %s <-> %s %s -> %s\n",
neighbor.RemoteMAC, matches[0].matchingMACs, neighborMACPrefix, targetIP) device.IP, neighbor.LocalInterface, d.IP, neighbor.RemoteInterface, targetIP)
} else if len(matches) > 1 { break
fmt.Printf(" ✗ Skipping MAC prefix match: %d devices share prefix %s (ambiguous)\n", len(matches), neighborMACPrefix) } 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网段匹配(新增) // 策略4: 通过本地接口IP网段匹配(新增)
// 注意:此策略容易产生误匹配,仅在必要时使用
if targetIP == "" { if targetIP == "" {
// 查找本地接口的IP地址 // 查找本地接口的IP地址
localInterfaceIP := "" localInterfaceIP := ""
@@ -211,7 +224,7 @@ func (b *Builder) Build() models.TopologyGraph {
} }
if localInterfaceIP != "" { 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) neighbor.LocalInterface, localInterfaceIP)
// 计算本地接口的网段 // 计算本地接口的网段
@@ -227,7 +240,7 @@ func (b *Builder) Build() models.TopologyGraph {
if getSubnet(d.IP) == localSubnet { if getSubnet(d.IP) == localSubnet {
targetIP = d.IP targetIP = d.IP
matchMethod = "subnet(management 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 break
} }
@@ -236,7 +249,7 @@ func (b *Builder) Build() models.TopologyGraph {
if iface.IP != "" && getSubnet(iface.IP) == localSubnet { if iface.IP != "" && getSubnet(iface.IP) == localSubnet {
targetIP = d.IP targetIP = d.IP
matchMethod = fmt.Sprintf("subnet(interface %s)", iface.Name) 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) d.IP, iface.Name, localSubnet)
break break
} }
@@ -246,27 +259,9 @@ func (b *Builder) Build() models.TopologyGraph {
} }
} }
} else { } else {
// 策略4b: 本地接口没有IP,尝试使用设备管理IP进行子网匹配(新增 // 策略4b: 本地接口没有IP,尝试使用设备管理IP进行子网匹配(高风险策略 - 已禁用
// 注意:只有当该网段只有2台设备时才使用此策略(点对点连接) // 此策略容易产生错误连接,暂时禁用
fmt.Printf(" Trying subnet match with management IP: %s\n", device.IP) fmt.Printf(" ✗ Skipping subnet match with management IP (disabled - too risky)\n")
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)
}
} }
} }
+33
View File
@@ -100,6 +100,8 @@ header h1 {
.sidebar { .sidebar {
width: 300px; width: 300px;
min-width: 200px;
max-width: 600px;
background: white; background: white;
padding: 20px; padding: 20px;
overflow-y: auto; overflow-y: auto;
@@ -190,6 +192,7 @@ header h1 {
.content { .content {
flex: 1; flex: 1;
position: relative; position: relative;
min-width: 400px;
} }
#cy { #cy {
@@ -200,17 +203,47 @@ header h1 {
.detail-panel { .detail-panel {
width: 600px; width: 600px;
min-width: 300px;
max-width: 1000px;
background: white; background: white;
padding: 20px; padding: 20px;
overflow-y: auto; overflow-y: auto;
box-shadow: -2px 0 5px rgba(0,0,0,0.1); box-shadow: -2px 0 5px rgba(0,0,0,0.1);
display: none; display: none;
position: relative;
} }
.detail-panel.active { .detail-panel.active {
display: block; 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 { .detail-panel h3 {
margin-bottom: 15px; margin-bottom: 15px;
color: #667eea; color: #667eea;
+7 -1
View File
@@ -29,7 +29,7 @@
<div class="main-content"> <div class="main-content">
<!-- 侧边栏 --> <!-- 侧边栏 -->
<aside class="sidebar"> <aside class="sidebar" id="sidebar">
<div class="panel"> <div class="panel">
<h3>扫描配置</h3> <h3>扫描配置</h3>
<form id="scan-form"> <form id="scan-form">
@@ -69,11 +69,17 @@
</div> </div>
</aside> </aside>
<!-- 左侧拖拽手柄 -->
<div class="resizer" id="resizer-left"></div>
<!-- 主内容区 --> <!-- 主内容区 -->
<main class="content"> <main class="content">
<div id="cy"></div> <div id="cy"></div>
</main> </main>
<!-- 右侧拖拽手柄 -->
<div class="resizer" id="resizer-right"></div>
<!-- 详情面板 --> <!-- 详情面板 -->
<aside class="detail-panel" id="detail-panel"> <aside class="detail-panel" id="detail-panel">
<h3>设备详情</h3> <h3>设备详情</h3>
+78
View File
@@ -908,3 +908,81 @@ async function saveEditDevice(event) {
alert('修改失败: ' + error.message); 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 = '';
}
});
}
})();