Browse Source

v1.0.1: 多拓扑管理、Web SSH终端、扫描进度修复、拓扑连线优化

- 修复扫描进度条不动的问题(分4阶段更新进度)
- 新增Web SSH远程终端(xterm.js + WebSocket)
- 新增多拓扑管理(创建/切换拓扑、全局设备池)
- 简化新建拓扑流程(仅需名称,创建后选择设备)
- 修复拓扑Builder设备去重(按IP去重)
- 修复启动时拓扑设备不加载到Builder的问题
- 优化MAC前缀匹配(避免歧义前缀导致错误连线)
- 拓扑连线改为无向(去除箭头)
- 设备详情面板加宽到600px
Your Name 1 month ago
parent
commit
44f7fef1f8

+ 3 - 0
.gitignore

@@ -23,3 +23,6 @@ config.json
 # 临时文件
 *.tmp
 *.bak
+
+# 运行时数据
+data/

+ 69 - 0
FIX_NEIGHBOR_DISCOVERY.md

@@ -0,0 +1,69 @@
+# 邻居信息获取问题修复说明
+
+## 问题描述
+网络拓扑发现系统无法正确获取设备间的邻居信息,导致拓扑图无法显示设备间的连接关系。
+
+## 根本原因分析
+
+1. **缺少ARP表支持**:原始代码没有利用ARP表来辅助邻居设备的IP地址匹配
+2. **解析器参数不完整**:H3C、Cisco和华为设备的解析器在解析邻居信息时未充分利用可用的ARP数据
+3. **MAC地址匹配不完善**:虽然有MAC地址匹配逻辑,但缺乏完整的IP-MAC映射支持
+
+## 修复方案
+
+### 1. H3C设备修复 (`internal/device/h3c.go`)
+- 添加 `display arp` 命令到命令列表
+- 修改 `Parse` 函数以接收6个输出而非5个
+- 实现ARP表解析功能,建立MAC到IP的映射
+- 在邻居解析过程中使用ARP表补充缺失的IP地址信息
+
+### 2. Cisco设备修复 (`internal/device/cisco.go`)
+- 添加 `show arp` 命令到命令列表
+- 修改 `Parse` 函数以接收6个输出而非5个
+- 实现ARP表解析功能,处理Cisco特有的MAC地址格式(点号分隔)
+- 在CDP和LLDP邻居解析中使用ARP表补充IP地址
+
+### 3. 华为设备修复 (`internal/device/huawei.go`)
+- 添加 `display arp` 命令到命令列表
+- 修改 `Parse` 函数以接收5个输出而非4个
+- 实现ARP表解析功能
+- 在LLDP邻居解析中使用ARP表信息
+
+## 技术改进
+
+### ARP表解析
+- 支持多种格式的ARP表输出
+- 自动标准化MAC地址格式(统一使用连字符分隔)
+- 验证IP和MAC地址的有效性
+
+### 邻居匹配增强
+- 优先使用LLDP/CDP提供的直接IP地址
+- 当缺少IP地址时,通过MAC地址在ARP表中查找对应IP
+- 保留原有的主机名匹配和MAC地址匹配机制
+
+### 调试信息增强
+- 添加详细的调试日志,便于排查问题
+- 显示ARP表条目数量
+- 记录通过ARP表成功匹配的邻居信息
+
+## 测试方法
+
+1. 运行 `test-fix.bat` 编译并启动应用
+2. 通过Web界面添加网络设备
+3. 检查控制台输出的调试信息
+4. 验证拓扑图中是否正确显示设备间连接
+
+## 预期效果
+
+修复后,系统应该能够:
+- 正确解析各种设备的LLDP/CDP邻居信息
+- 通过ARP表补充缺失的邻居IP地址
+- 在拓扑图中准确显示设备间的连接关系
+- 提供更详细的调试信息用于问题排查
+
+## 注意事项
+
+- 确保网络设备已启用LLDP或CDP协议
+- 确保设备上有完整的ARP表项
+- 某些老旧设备可能需要更长的超时时间
+- 防火墙规则不应阻止LLDP/CDP数据包

+ 140 - 0
SSH_OUTPUT_FIX.md

@@ -0,0 +1,140 @@
+# SSH命令输出处理修复说明
+
+## 🔍 **问题诊断**
+
+从调试输出中发现的关键问题:
+
+### 1. **SSH命令输出混乱**
+```
+[H3C ARP DEBUG] First 500 chars of ARP Output:
+******************************************************************************
+
+screen-length disable
+<BJ-FW01-New>screen-length disable
+<BJ-FW01-New>echo ===CMD_BOUNDARY_0===
+^
+% Unrecognized command found at '^' position.
+<BJ-FW01-New>display version
+H3C Comware Software, Version 7.1.064, Release 9323P25
+...
+```
+
+**问题分析**:
+- ARP命令的输出包含了前面所有命令的输出(version、interface等)
+- 分隔符处理逻辑有缺陷,导致命令输出混在一起
+- `screen-length disable` 命令失败,但影响了后续处理
+
+### 2. **根本原因**
+
+SSH客户端的 `ExecuteCommands` 函数存在以下问题:
+
+1. **缓冲区累积**:所有命令的输出都写入同一个 `stdoutBuf`,没有清空
+2. **分隔符处理错误**:使用 `strings.Split` 分割,取倒数第二部分,导致前面的命令输出被包含
+3. **清理逻辑不足**:`cleanCommandOutput` 无法有效处理混杂的输出
+
+## ✅ **修复方案**
+
+### 1. **改进分隔符处理逻辑**
+```go
+// 记录上一个分隔符的位置
+lastDelimiter := ""
+
+// 正确提取当前命令的输出
+if lastDelimiter != "" {
+    // 找到上一个分隔符和当前分隔符之间的内容
+    startIdx := strings.LastIndex(rawOutput, lastDelimiter)
+    endIdx := strings.LastIndex(rawOutput, delimiter)
+    
+    if startIdx >= 0 && endIdx >= 0 && endIdx > startIdx {
+        cmdOutput = rawOutput[startIdx+len(lastDelimiter):endIdx]
+    }
+} else {
+    // 第一个命令:从开头到第一个分隔符
+    endIdx := strings.Index(rawOutput, delimiter)
+    if endIdx >= 0 {
+        cmdOutput = rawOutput[:endIdx]
+    }
+}
+
+lastDelimiter = delimiter
+```
+
+### 2. **清空缓冲区**
+```go
+// 每次命令执行后清空缓冲区,避免累积(重要!)
+stdoutBuf.Reset()
+stderrBuf.Reset()
+```
+
+### 3. **增加等待时间**
+```go
+sleepTime := 1 * time.Second
+if cmd == "display interface" || strings.Contains(cmd, "display interface") {
+    sleepTime = 10 * time.Second // 大输出命令需要更多时间
+} else if strings.Contains(cmd, "display lldp") {
+    sleepTime = 5 * time.Second
+} else if strings.Contains(cmd, "display arp") {
+    sleepTime = 5 * time.Second
+}
+```
+
+### 4. **添加调试信息**
+```go
+fmt.Printf("[SSH DEBUG] Sending command: %s\n", cmd)
+fmt.Printf("[SSH DEBUG] Extracted between delimiters: start=%d, end=%d\n", startIdx, endIdx)
+```
+
+## 🚀 **预期效果**
+
+修复后,系统应该能够:
+
+1. **正确分离每个命令的输出**
+   - ARP命令只包含ARP表数据
+   - LLDP命令只包含LLDP邻居信息
+   - 不再有命令输出混杂的问题
+
+2. **正确解析ARP表**
+   - 解析器能够正确处理纯净的ARP表数据
+   - 建立正确的IP-MAC映射
+
+3. **正确解析LLDP邻居**
+   - 解析器能够正确处理纯净的LLDP输出
+   - 提取邻居设备信息
+
+4. **显示更清晰的调试信息**
+   - 命令发送过程
+   - 分隔符提取过程
+   - 输出长度信息
+
+## 📝 **测试步骤**
+
+1. 运行修复后的程序:
+   ```bash
+   .\network-topology.exe
+   ```
+
+2. 通过Web界面添加设备 `172.16.8.1`
+
+3. 查看控制台输出的调试信息:
+   - `[SSH DEBUG]` - SSH命令发送和提取过程
+   - `[H3C ARP DEBUG]` - ARP表解析详情
+   - `[H3C LLDP DEBUG]` - LLDP邻居解析详情
+
+4. 验证结果:
+   - ARP表应该有多个条目(不再是0)
+   - LLDP邻居应该有数据(如果设备上有邻居)
+   - 拓扑图应该显示设备间连接
+
+## ⚠️ **注意事项**
+
+1. **`screen-length disable` 命令失败**:某些H3C设备版本不支持此命令,但已通过清空缓冲区解决
+2. **命令等待时间**:大输出命令(如 `display interface`)需要更长时间等待
+3. **设备兼容性**:修复后的代码能更好地处理不同设备类型的输出
+
+## 🔧 **后续改进**
+
+如果问题仍然存在,可以考虑:
+
+1. **改进清理逻辑**:更智能地识别和过滤命令回显
+2. **错误处理**:忽略失败的命令(如 `screen-length disable`)
+3. **兼容性检测**:自动检测设备类型,使用正确的命令集

+ 140 - 0
V1.0.0_COMPARISON.md

@@ -0,0 +1,140 @@
+# v1.0.0版本对比分析 - 邻居发现修复
+
+## 🔍 **关键发现**
+
+通过对比v1.0.0分支的代码,发现了成功获取邻居信息的关键因素:
+
+### **v1.0.0的成功配置**:
+
+1. **命令列表简化**
+   ```go
+   // v1.0.0版本 - 成功的配置
+   "display lldp neighbor-information",  // 非verbose格式
+   ```
+   
+   而不是:
+   ```go
+   // 失败的配置
+   "display lldp neighbor-information verbose",  // verbose格式导致解析复杂
+   "display arp",  // ARP命令增加复杂性
+   ```
+
+2. **parseNeighbors函数简化**
+   - 只处理非verbose格式的LLDP输出
+   - 使用简单的 `ChassisID/subtype` 和 `PortID/subtype` 格式
+   - 不需要处理复杂的verbose格式(Chassis ID、System name、Management address等)
+
+3. **SSH命令输出处理**
+   - v1.0.0也存在SSH输出混杂问题,但由于命令数量少(5个),问题不明显
+   - 但我们已经修复了SSH输出处理问题(清空缓冲区+分隔符改进)
+
+## ✅ **我们的改进**
+
+### **保持v1.0.0的成功要素**:
+
+1. **改回非verboseLLDP命令**
+   - 使用 `"display lldp neighbor-information"`
+   - 移除verbose和ARP命令
+
+2. **简化parseNeighbors函数**
+   - 只处理非verbose格式:`ChassisID/subtype` 和 `PortID/subtype`
+   - 移除复杂的verbose格式处理逻辑
+   - 保留v1.0.0的简单解析逻辑
+
+### **增加额外的改进**:
+
+3. **SSH输出处理优化**
+   - 添加缓冲区清空(`stdoutBuf.Reset()`)
+   - 改进分隔符处理逻辑
+   - 增加更合理的等待时间
+
+4. **调试信息增强**
+   - 添加详细的日志输出
+   - 方便排查问题
+
+## 📝 **关键差异对比**
+
+| 项目 | v1.0.0版本 | 之前失败版本 | 现在修复版本 |
+|------|------------|--------------|--------------|
+| **LLDP命令** | 非verbose | verbose | 非verbose ✅ |
+| **命令数量** | 5个 | 6个(含ARP) | 5个 ✅ |
+| **parseNeighbors** | 简单逻辑 | 复杂逻辑 | 简单逻辑 ✅ |
+| **SSH处理** | 有问题但影响小 | 问题明显 | 已修复 ✅ |
+| **ARP支持** | 无 | 有但未正确处理 | 无(保持v1.0.0) ✅ |
+
+## 🎯 **为什么verbose版本失败?**
+
+### **输出格式复杂**:
+```
+非verbose格式(成功):
+LLDP neighbor-information of port 20[GigabitEthernet1/0/20]:
+ChassisID/subtype: a4bb-6de2-62cd/MAC address
+PortID/subtype: GigabitEthernet0/0/1/Interface name
+
+verbose格式(失败):
+LLDP neighbor-information of port 20[GigabitEthernet1/0/20]:
+Chassis ID: 642f-c7e0-0333
+System name: RemoteDeviceHostname
+Port ID type: Interface name(7)
+Port ID: GigabitEthernet1/0/48
+Management address: 192.168.1.1
+...
+```
+
+### **SSH输出混杂问题**:
+- verbose版本的输出更长,更容易受到SSH命令混杂的影响
+- 更多的行数导致解析器更容易被前面命令的输出干扰
+
+## 🚀 **预期效果**
+
+修复后应该能够:
+
+1. **成功获取LLDP邻居**
+   - 使用v1.0.0验证成功的非verbose格式
+   - parseNeighbors简化为只处理必要信息
+
+2. **避免SSH输出混杂**
+   - 清空缓冲区防止累积
+   - 改进的分隔符处理
+
+3. **正确建立拓扑连接**
+   - 通过MAC地址识别邻居设备
+   - 建立设备间的连接关系
+
+## 🧪 **测试建议**
+
+1. 运行修复后的程序:
+   ```bash
+   .\network-topology.exe
+   ```
+
+2. 添加设备 `172.16.8.1`(H3C设备)
+
+3. 查看调试输出:
+   ```
+   Parsed neighbor MAC: mac-address (from line: ...)
+   Parsed neighbor interface: GigabitEthernetX/X/X
+   Device 172.16.8.1: 25 interfaces, X neighbors
+   ```
+
+4. 验证拓扑图显示邻居连接
+
+## ⚠️ **注意事项**
+
+1. **v1.0.0版本的SSH处理也有问题**,但由于命令少,影响较小
+2. **verbose格式理论上提供更多信息**,但SSH处理问题导致失败
+3. **如果需要更多信息(System name、Management address)**:
+   - 可以在SSH处理完全稳定后,重新尝试verbose格式
+   - 或者通过其他命令补充信息
+
+## 📊 **总结**
+
+v1.0.0版本成功的关键:
+- **简单即是有效** - 少即是多
+- **非verbose格式** - 输出短,解析简单
+- **少的命令数量** - 减少SSH输出混杂的影响
+
+我们现在的修复:
+- **回归v1.0.0的成功配置**
+- **修复SSH处理问题**
+- **保持简单有效的策略**

+ 411 - 24
cmd/main.go

@@ -15,36 +15,56 @@ import (
 	"network-topology-discovery/internal/device"
 	"network-topology-discovery/internal/scanner"
 	"network-topology-discovery/internal/storage"
+	"network-topology-discovery/internal/terminal"
 	"network-topology-discovery/internal/topology"
 	"network-topology-discovery/pkg/models"
 )
 
 // App 应用
 type App struct {
-	config     *config.Config
-	builder    *topology.Builder
-	storage    *storage.Storage
-	tasks      map[string]*models.ScanTask
-	mu         sync.RWMutex
-	httpServer *http.Server
+	config          *config.Config
+	builder         *topology.Builder
+	storage         *storage.Storage
+	topologyStorage *storage.TopologyStorage
+	tasks           map[string]*models.ScanTask
+	mu              sync.RWMutex
+	httpServer      *http.Server
 }
 
 // NewApp 创建应用
 func NewApp(cfg *config.Config) *App {
-	// 初始化存储(使用JSON文件)
+	// 初始化拓扑存储(管理多个拓扑)
+	topoStorage, err := storage.NewTopologyStorage("data")
+	if err != nil {
+		log.Printf("Warning: failed to initialize topology storage: %v", err)
+	}
+
+	// 初始化设备存储(使用默认文件,兼容旧版)
 	store, err := storage.NewStorage("devices.json")
 	if err != nil {
 		log.Printf("Warning: failed to initialize storage: %v", err)
 	}
 
 	app := &App{
-		config:  cfg,
-		builder: topology.NewBuilder(),
-		storage: store,
-		tasks:   make(map[string]*models.ScanTask),
+		config:          cfg,
+		builder:         topology.NewBuilder(),
+		storage:         store,
+		topologyStorage: topoStorage,
+		tasks:           make(map[string]*models.ScanTask),
+	}
+
+	// 如果有拓扑存储,切换到第一个拓扑
+	if topoStorage != nil {
+		topos, err := topoStorage.GetAllTopologies()
+		if err == nil && len(topos) > 0 {
+			topoStorage.SetCurrentTopology(topos[0].ID)
+			deviceFile := topoStorage.GetDeviceFilePath(topos[0].ID)
+			app.storage.SetFilePath(deviceFile)
+			log.Printf("Loaded topology: %s", topos[0].Name)
+		}
 	}
 
-	// 从数据库加载设备到拓扑构建器
+	// 加载设备到拓扑构建器
 	if store != nil {
 		devices, err := store.GetAllDevices()
 		if err != nil {
@@ -84,6 +104,18 @@ func (app *App) Start() error {
 	mux.HandleFunc("/api/device", app.handleAddDevice)
 	mux.HandleFunc("/api/device/{id}", app.handleDeviceDetail)
 
+	// 拓扑管理API
+	mux.HandleFunc("/api/topologies", app.handleTopologies)
+	mux.HandleFunc("/api/topology/switch", app.handleSwitchTopology)
+	mux.HandleFunc("/api/topology/{id}", app.handleTopologyDetail)
+
+	// SSH终端API
+	mux.HandleFunc("/api/terminal", app.handleTerminalConnect)
+
+	// 全局设备池API
+	mux.HandleFunc("/api/devices/all", app.handleGetAllDevices)
+	mux.HandleFunc("/api/topology/{id}/devices", app.handleAddDevicesToTopology)
+
 	addr := fmt.Sprintf("%s:%d", app.config.Web.Host, app.config.Web.Port)
 	app.httpServer = &http.Server{
 		Addr:    addr,
@@ -157,6 +189,16 @@ func (app *App) handleScan(w http.ResponseWriter, r *http.Request) {
 	app.tasks[taskID] = task
 	app.mu.Unlock()
 
+	// 确保当前有拓扑
+	if app.topologyStorage != nil && app.topologyStorage.GetCurrentTopologyID() == "" {
+		topos, err := app.topologyStorage.GetAllTopologies()
+		if err == nil && len(topos) > 0 {
+			app.topologyStorage.SetCurrentTopology(topos[0].ID)
+			deviceFile := app.topologyStorage.GetDeviceFilePath(topos[0].ID)
+			app.storage.SetFilePath(deviceFile)
+		}
+	}
+
 	// 异步执行扫描
 	go app.runScan(task, req.ScanRange, req.SSHPort, req.Username, req.Password)
 
@@ -173,19 +215,41 @@ func (app *App) runScan(task *models.ScanTask, cidr string, sshPort int, usernam
 	// 创建扫描器
 	sc := scanner.NewScanner(app.config.Scanner.Concurrency, time.Duration(app.config.Scanner.Timeout)*time.Second)
 
-	// 扫描SSH主机
-	sshHosts, err := sc.ScanAndDiscover(cidr, sshPort)
+	// 阶段1: 解析IP范围 (进度 0% -> 10%)
+	ips, err := sc.ScanRange(cidr)
 	if err != nil {
 		task.Status = "failed"
 		task.ErrorMessage = err.Error()
 		return
 	}
-
+	task.TotalDevices = len(ips)
+	task.Progress = 10
+	log.Printf("[扫描] 阶段1: 解析CIDR %s, 共 %d 个IP", cidr, len(ips))
+
+	// 阶段2: 检查存活主机 (进度 10% -> 30%)
+	aliveHosts := sc.CheckHosts(ips)
+	task.Progress = 30
+	log.Printf("[扫描] 阶段2: 发现 %d 个存活主机", len(aliveHosts))
+
+	// 阶段3: 检查SSH端口 (进度 30% -> 50%)
+	sshHosts := sc.CheckSSHHosts(aliveHosts, sshPort)
+	task.Progress = 50
 	task.TotalDevices = len(sshHosts)
+	log.Printf("[扫描] 阶段3: 发现 %d 个SSH主机", len(sshHosts))
+
+	// 如果没有SSH主机,直接完成
+	if len(sshHosts) == 0 {
+		task.Status = "completed"
+		task.Progress = 100
+		log.Printf("[扫描] 未发现SSH主机,扫描完成")
+		return
+	}
 
-	// 采集设备信息
+	// 阶段4: 采集设备信息 (进度 50% -> 100%)
 	var devices []models.Device
 	for i, ip := range sshHosts {
+		log.Printf("[扫描] 阶段4: 正在采集设备 %s (%d/%d)", ip, i+1, len(sshHosts))
+
 		// 尝试不同设备类型
 		deviceTypes := []models.DeviceType{
 			models.DeviceTypeCisco,
@@ -208,7 +272,7 @@ func (app *App) runScan(task *models.ScanTask, cidr string, sshPort int, usernam
 		if discoveredDevice != nil {
 			devices = append(devices, *discoveredDevice)
 			app.builder.AddDevice(*discoveredDevice)
-			
+
 			// 保存到数据库
 			if app.storage != nil {
 				if err := app.storage.SaveDevice(discoveredDevice); err != nil {
@@ -217,21 +281,22 @@ func (app *App) runScan(task *models.ScanTask, cidr string, sshPort int, usernam
 			}
 		}
 
-		// 更新进度
+		// 更新进度: 50% ~ 100%
 		task.ScannedDevices = i + 1
-		task.Progress = (i + 1) * 100 / len(sshHosts)
+		task.Progress = 50 + (i+1)*50/len(sshHosts)
 		task.Devices = devices
 	}
 
 	task.Status = "completed"
 	task.Progress = 100
 	task.Devices = devices
+	log.Printf("[扫描] 完成,共发现 %d 台设备", len(devices))
 }
 
 // 处理扫描进度查询
 func (app *App) handleScanProgress(w http.ResponseWriter, r *http.Request) {
 	id := r.PathValue("id")
-	
+
 	app.mu.RLock()
 	task, exists := app.tasks[id]
 	app.mu.RUnlock()
@@ -256,7 +321,7 @@ func (app *App) handleTopology(w http.ResponseWriter, r *http.Request) {
 // 处理获取所有设备
 func (app *App) handleGetDevices(w http.ResponseWriter, r *http.Request) {
 	var devices []models.Device
-	
+
 	// 优先从存储获取
 	if app.storage != nil {
 		var err error
@@ -297,7 +362,7 @@ func (app *App) handleAddDevice(w http.ResponseWriter, r *http.Request) {
 
 	deviceType := models.DeviceType(req.Type)
 	log.Printf("Adding device: %s (type: %s)", req.IP, req.Type)
-	
+
 	dev, err := device.DiscoverDevice(req.IP, deviceType, req.Username, req.Password)
 	if err != nil {
 		log.Printf("Failed to discover device %s: %v", req.IP, err)
@@ -307,7 +372,7 @@ func (app *App) handleAddDevice(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	log.Printf("Device discovered: %s, interfaces: %d, neighbors: %d", 
+	log.Printf("Device discovered: %s, interfaces: %d, neighbors: %d",
 		dev.IP, len(dev.Interfaces), len(dev.Neighbors))
 
 	app.builder.AddDevice(*dev)
@@ -341,6 +406,328 @@ func (app *App) handleDeviceDetail(w http.ResponseWriter, r *http.Request) {
 	http.Error(w, "Device not found", http.StatusNotFound)
 }
 
+// 处理拓扑列表(GET: 获取所有拓扑,POST: 创建新拓扑)
+func (app *App) handleTopologies(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/json")
+
+	if r.Method == http.MethodGet {
+		// 获取所有拓扑
+		if app.topologyStorage == nil {
+			json.NewEncoder(w).Encode([]models.Topology{})
+			return
+		}
+
+		topos, err := app.topologyStorage.GetAllTopologies()
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		// 更新每个拓扑的设备数量
+		currentTopoID := app.topologyStorage.GetCurrentTopologyID()
+		for i := range topos {
+			deviceFile := app.topologyStorage.GetDeviceFilePath(topos[i].ID)
+			store, err := storage.NewStorage(deviceFile)
+			if err == nil {
+				devices, err := store.GetAllDevices()
+				if err == nil {
+					topos[i].DeviceCount = len(devices)
+				}
+			}
+			// 标记当前拓扑
+			if topos[i].ID == currentTopoID {
+				topos[i].Name = topos[i].Name + " (当前)"
+			}
+		}
+
+		json.NewEncoder(w).Encode(topos)
+	} else if r.Method == http.MethodPost {
+		// 创建新拓扑
+		var req struct {
+			Name        string `json:"name"`
+			Description string `json:"description"`
+			ScanRange   string `json:"scan_range"`
+			SSHPort     int    `json:"ssh_port"`
+			Username    string `json:"username"`
+		}
+
+		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		if req.Name == "" {
+			http.Error(w, "name is required", http.StatusBadRequest)
+			return
+		}
+
+		topo, err := app.topologyStorage.CreateTopology(req.Name, req.Description, req.ScanRange, req.SSHPort, req.Username)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		// 自动切换到新拓扑
+		app.topologyStorage.SetCurrentTopology(topo.ID)
+		deviceFile := app.topologyStorage.GetDeviceFilePath(topo.ID)
+		app.storage.SetFilePath(deviceFile)
+		app.builder.Clear()
+
+		log.Printf("Created and switched to new topology: %s", topo.Name)
+		json.NewEncoder(w).Encode(topo)
+	} else {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+	}
+}
+
+// 处理切换拓扑
+func (app *App) handleSwitchTopology(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	var req struct {
+		TopologyID string `json:"topology_id"`
+	}
+
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	if app.topologyStorage == nil {
+		http.Error(w, "Topology storage not available", http.StatusInternalServerError)
+		return
+	}
+
+	// 验证拓扑存在
+	topo, err := app.topologyStorage.GetTopology(req.TopologyID)
+	if err != nil {
+		http.Error(w, "Topology not found", http.StatusNotFound)
+		return
+	}
+
+	// 切换拓扑
+	err = app.topologyStorage.SetCurrentTopology(req.TopologyID)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	// 切换设备存储文件
+	deviceFile := app.topologyStorage.GetDeviceFilePath(req.TopologyID)
+	err = app.storage.SetFilePath(deviceFile)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	// 清空并重新加载拓扑构建器
+	app.builder.Clear()
+	devices, err := app.storage.GetAllDevices()
+	if err == nil {
+		for _, dev := range devices {
+			app.builder.AddDevice(dev)
+		}
+	}
+
+	log.Printf("Switched to topology: %s (%s)", topo.Name, req.TopologyID)
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]string{"message": "Topology switched successfully"})
+}
+
+// 处理拓扑详情
+func (app *App) handleTopologyDetail(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	if app.topologyStorage == nil {
+		http.Error(w, "Topology storage not available", http.StatusInternalServerError)
+		return
+	}
+
+	topo, err := app.topologyStorage.GetTopology(id)
+	if err != nil {
+		http.Error(w, "Topology not found", http.StatusNotFound)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(topo)
+}
+
+// 处理SSH终端连接
+func (app *App) handleTerminalConnect(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		// WebSocket 连接使用 GET,但我们通过 POST 获取凭据后再升级
+		if r.Method != http.MethodGet {
+			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+			return
+		}
+	}
+
+	// 从查询参数获取设备IP
+	ip := r.URL.Query().Get("ip")
+	if ip == "" {
+		http.Error(w, "ip parameter is required", http.StatusBadRequest)
+		return
+	}
+
+	port := 22
+	if p := r.URL.Query().Get("port"); p != "" {
+		fmt.Sscanf(p, "%d", &port)
+	}
+
+	username := r.URL.Query().Get("username")
+	password := r.URL.Query().Get("password")
+
+	if username == "" || password == "" {
+		http.Error(w, "username and password are required", http.StatusBadRequest)
+		return
+	}
+
+	log.Printf("[终端] 正在连接 %s:%d (%s)", ip, port, username)
+
+	// 使用 terminal handler 处理 WebSocket 连接
+	terminal.HandleTerminal(w, r, ip, port, username, password)
+}
+
+// 获取所有拓扑中的全部设备(全局设备池)
+func (app *App) handleGetAllDevices(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodGet {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	deviceMap := make(map[string]models.Device) // 用IP去重
+
+	if app.topologyStorage != nil {
+		topos, err := app.topologyStorage.GetAllTopologies()
+		if err == nil {
+			for _, topo := range topos {
+				deviceFile := app.topologyStorage.GetDeviceFilePath(topo.ID)
+				store, err := storage.NewStorage(deviceFile)
+				if err != nil {
+					continue
+				}
+				devices, err := store.GetAllDevices()
+				if err != nil {
+					continue
+				}
+				for _, dev := range devices {
+					deviceMap[dev.IP] = dev
+				}
+			}
+		}
+	}
+
+	// 也从旧版 devices.json 加载
+	if app.storage != nil {
+		devices, err := app.storage.GetAllDevices()
+		if err == nil {
+			for _, dev := range devices {
+				deviceMap[dev.IP] = dev
+			}
+		}
+	}
+
+	devices := make([]models.Device, 0, len(deviceMap))
+	for _, dev := range deviceMap {
+		devices = append(devices, dev)
+	}
+
+	log.Printf("[设备池] 返回 %d 个全局设备", len(devices))
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(devices)
+}
+
+// 向指定拓扑批量添加设备
+func (app *App) handleAddDevicesToTopology(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	topoID := r.PathValue("id")
+
+	var req struct {
+		DeviceIDs []string `json:"device_ids"` // IP列表或ID列表
+	}
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	if len(req.DeviceIDs) == 0 {
+		http.Error(w, "device_ids is required", http.StatusBadRequest)
+		return
+	}
+
+	// 获取目标拓扑的存储
+	if app.topologyStorage == nil {
+		http.Error(w, "Topology storage not available", http.StatusInternalServerError)
+		return
+	}
+
+	topo, err := app.topologyStorage.GetTopology(topoID)
+	if err != nil {
+		http.Error(w, "Topology not found", http.StatusNotFound)
+		return
+	}
+
+	deviceFile := app.topologyStorage.GetDeviceFilePath(topoID)
+	targetStore, err := storage.NewStorage(deviceFile)
+	if err != nil {
+		http.Error(w, "Failed to open target storage", http.StatusInternalServerError)
+		return
+	}
+
+	// 从全局设备池中查找并添加
+	allDeviceMap := make(map[string]models.Device)
+	topos, _ := app.topologyStorage.GetAllTopologies()
+	for _, t := range topos {
+		df := app.topologyStorage.GetDeviceFilePath(t.ID)
+		s, err := storage.NewStorage(df)
+		if err != nil {
+			continue
+		}
+		devs, err := s.GetAllDevices()
+		if err != nil {
+			continue
+		}
+		for _, d := range devs {
+			allDeviceMap[d.IP] = d
+			allDeviceMap[d.ID] = d
+		}
+	}
+
+	added := 0
+	for _, idOrIP := range req.DeviceIDs {
+		if dev, ok := allDeviceMap[idOrIP]; ok {
+			devCopy := dev // 复制一份
+			targetStore.SaveDevice(&devCopy)
+			added++
+		}
+	}
+
+	// 如果是当前拓扑,刷新builder
+	if app.topologyStorage.GetCurrentTopologyID() == topoID {
+		app.builder.Clear()
+		devices, _ := targetStore.GetAllDevices()
+		for _, dev := range devices {
+			app.builder.AddDevice(dev)
+		}
+	}
+
+	log.Printf("[拓扑] 向 %s 添加了 %d/%d 设备", topo.Name, added, len(req.DeviceIDs))
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]interface{}{
+		"added":    added,
+		"total":    len(req.DeviceIDs),
+		"topology": topo.Name,
+	})
+}
+
 func main() {
 	// 加载配置
 	configFile := "config.json"
@@ -362,7 +749,7 @@ func main() {
 
 	// 创建并启动应用
 	app := NewApp(cfg)
-	
+
 	log.Println("网络拓扑发现系统启动...")
 	if err := app.Start(); err != nil {
 		log.Fatalf("服务启动失败: %v", err)

+ 6 - 0
debug-neighbor.bat

@@ -0,0 +1,6 @@
+@echo off
+echo Starting network topology discovery with enhanced debugging...
+echo This will show detailed ARP and LLDP parsing information.
+echo.
+network-topology.exe
+pause

+ 4 - 1
go.mod

@@ -4,4 +4,7 @@ go 1.26.2
 
 require golang.org/x/crypto v0.50.0
 
-require golang.org/x/sys v0.43.0 // indirect
+require (
+	github.com/gorilla/websocket v1.5.3 // indirect
+	golang.org/x/sys v0.43.0 // indirect
+)

+ 2 - 0
go.sum

@@ -1,3 +1,5 @@
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
 golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
 golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=

+ 25 - 2
internal/storage/storage.go

@@ -4,9 +4,9 @@ import (
 	"encoding/json"
 	"fmt"
 	"log"
+	"network-topology-discovery/pkg/models"
 	"os"
 	"sync"
-	"network-topology-discovery/pkg/models"
 	"time"
 )
 
@@ -17,8 +17,13 @@ type Storage struct {
 	devices  map[string]models.Device
 }
 
-// NewStorage 创建存储实例
+// NewStorage 创建存储实例(兼容旧版,使用默认文件)
 func NewStorage(filePath string) (*Storage, error) {
+	return NewStorageForTopology(filePath)
+}
+
+// NewStorageForTopology 为特定拓扑创建存储实例
+func NewStorageForTopology(filePath string) (*Storage, error) {
 	s := &Storage{
 		filePath: filePath,
 		devices:  make(map[string]models.Device),
@@ -36,6 +41,24 @@ func NewStorage(filePath string) (*Storage, error) {
 	return s, nil
 }
 
+// SetFilePath 切换存储文件路径(用于切换拓扑)
+func (s *Storage) SetFilePath(filePath string) error {
+	s.mu.Lock()
+	s.filePath = filePath
+	s.devices = make(map[string]models.Device)
+	s.mu.Unlock()
+
+	// 重新加载数据
+	if err := s.load(); err != nil {
+		if !os.IsNotExist(err) {
+			return fmt.Errorf("failed to load storage: %w", err)
+		}
+	}
+
+	log.Printf("Storage switched to: %s", filePath)
+	return nil
+}
+
 // load 从文件加载数据
 func (s *Storage) load() error {
 	data, err := os.ReadFile(s.filePath)

+ 237 - 0
internal/storage/topology_storage.go

@@ -0,0 +1,237 @@
+package storage
+
+import (
+	"crypto/rand"
+	"encoding/json"
+	"fmt"
+	"log"
+	"os"
+	"path/filepath"
+	"sync"
+	"time"
+
+	"network-topology-discovery/pkg/models"
+)
+
+// TopologyStorage 拓扑存储管理
+type TopologyStorage struct {
+	mu            sync.RWMutex
+	dataDir       string
+	topologies    map[string]*models.Topology
+	currentTopoID string
+}
+
+// NewTopologyStorage 创建拓扑存储
+func NewTopologyStorage(dataDir string) (*TopologyStorage, error) {
+	s := &TopologyStorage{
+		dataDir:    dataDir,
+		topologies: make(map[string]*models.Topology),
+	}
+
+	// 确保数据目录存在
+	if err := os.MkdirAll(dataDir, 0755); err != nil {
+		return nil, fmt.Errorf("failed to create data directory: %w", err)
+	}
+
+	// 加载拓扑元数据
+	if err := s.loadMeta(); err != nil {
+		if !os.IsNotExist(err) {
+			return nil, fmt.Errorf("failed to load topology meta: %w", err)
+		}
+		log.Printf("Creating new topology storage at %s", dataDir)
+	}
+
+	return s, nil
+}
+
+// loadMeta 加载拓扑元数据
+func (s *TopologyStorage) loadMeta() error {
+	metaFile := filepath.Join(s.dataDir, "topologies.json")
+	data, err := os.ReadFile(metaFile)
+	if err != nil {
+		return err
+	}
+
+	var topos []models.Topology
+	if err := json.Unmarshal(data, &topos); err != nil {
+		return fmt.Errorf("failed to parse topology meta: %w", err)
+	}
+
+	for i := range topos {
+		s.topologies[topos[i].ID] = &topos[i]
+	}
+
+	log.Printf("Loaded %d topologies from meta", len(topos))
+	return nil
+}
+
+// saveMeta 保存拓扑元数据
+func (s *TopologyStorage) saveMeta() error {
+	topos := make([]models.Topology, 0, len(s.topologies))
+	for _, topo := range s.topologies {
+		topos = append(topos, *topo)
+	}
+
+	data, err := json.MarshalIndent(topos, "", "  ")
+	if err != nil {
+		return fmt.Errorf("failed to marshal topologies: %w", err)
+	}
+
+	metaFile := filepath.Join(s.dataDir, "topologies.json")
+	if err := os.WriteFile(metaFile, data, 0644); err != nil {
+		return fmt.Errorf("failed to write topology meta: %w", err)
+	}
+
+	return nil
+}
+
+// CreateTopology 创建新拓扑
+func (s *TopologyStorage) CreateTopology(name, description, scanRange string, sshPort int, username string) (*models.Topology, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	topo := &models.Topology{
+		ID:          generateID(),
+		Name:        name,
+		Description: description,
+		ScanRange:   scanRange,
+		SSHPort:     sshPort,
+		Username:    username,
+		CreatedAt:   time.Now(),
+		UpdatedAt:   time.Now(),
+		DeviceCount: 0,
+	}
+
+	s.topologies[topo.ID] = topo
+
+	// 创建该拓扑的设备文件
+	deviceFile := filepath.Join(s.dataDir, topo.ID+"_devices.json")
+	if _, err := os.Stat(deviceFile); os.IsNotExist(err) {
+		if err := os.WriteFile(deviceFile, []byte("[]"), 0644); err != nil {
+			return nil, fmt.Errorf("failed to create device file: %w", err)
+		}
+	}
+
+	// 保存元数据
+	if err := s.saveMeta(); err != nil {
+		return nil, err
+	}
+
+	log.Printf("Topology created: %s (%s)", topo.Name, topo.ID)
+	return topo, nil
+}
+
+// GetTopology 获取拓扑
+func (s *TopologyStorage) GetTopology(id string) (*models.Topology, error) {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	topo, exists := s.topologies[id]
+	if !exists {
+		return nil, fmt.Errorf("topology not found: %s", id)
+	}
+
+	return topo, nil
+}
+
+// GetAllTopologies 获取所有拓扑
+func (s *TopologyStorage) GetAllTopologies() ([]models.Topology, error) {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	topos := make([]models.Topology, 0, len(s.topologies))
+	for _, topo := range s.topologies {
+		topos = append(topos, *topo)
+	}
+
+	return topos, nil
+}
+
+// UpdateTopology 更新拓扑
+func (s *TopologyStorage) UpdateTopology(id string, updates map[string]interface{}) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	topo, exists := s.topologies[id]
+	if !exists {
+		return fmt.Errorf("topology not found: %s", id)
+	}
+
+	if name, ok := updates["name"].(string); ok {
+		topo.Name = name
+	}
+	if desc, ok := updates["description"].(string); ok {
+		topo.Description = desc
+	}
+	if scanRange, ok := updates["scan_range"].(string); ok {
+		topo.ScanRange = scanRange
+	}
+	if sshPort, ok := updates["ssh_port"].(float64); ok {
+		topo.SSHPort = int(sshPort)
+	}
+	if username, ok := updates["username"].(string); ok {
+		topo.Username = username
+	}
+
+	topo.UpdatedAt = time.Now()
+
+	return s.saveMeta()
+}
+
+// DeleteTopology 删除拓扑及其所有数据
+func (s *TopologyStorage) DeleteTopology(id string) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if _, exists := s.topologies[id]; !exists {
+		return fmt.Errorf("topology not found: %s", id)
+	}
+
+	// 删除设备文件
+	deviceFile := filepath.Join(s.dataDir, id+"_devices.json")
+	if err := os.Remove(deviceFile); err != nil && !os.IsNotExist(err) {
+		log.Printf("Warning: failed to delete device file for topology %s: %v", id, err)
+	}
+
+	delete(s.topologies, id)
+
+	// 更新当前拓扑
+	if s.currentTopoID == id {
+		s.currentTopoID = ""
+	}
+
+	return s.saveMeta()
+}
+
+// SetCurrentTopology 设置当前拓扑
+func (s *TopologyStorage) SetCurrentTopology(id string) error {
+	if _, err := s.GetTopology(id); err != nil {
+		return err
+	}
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	s.currentTopoID = id
+	log.Printf("Current topology set to: %s", id)
+	return nil
+}
+
+// GetCurrentTopologyID 获取当前拓扑ID
+func (s *TopologyStorage) GetCurrentTopologyID() string {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	return s.currentTopoID
+}
+
+// GetDeviceFilePath 获取拓扑的设备文件路径
+func (s *TopologyStorage) GetDeviceFilePath(topoID string) string {
+	return filepath.Join(s.dataDir, topoID+"_devices.json")
+}
+
+// generateID 生成唯一ID
+func generateID() string {
+	b := make([]byte, 16)
+	rand.Read(b)
+	return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
+}

+ 249 - 0
internal/terminal/handler.go

@@ -0,0 +1,249 @@
+package terminal
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"sync"
+	"time"
+
+	"github.com/gorilla/websocket"
+	"golang.org/x/crypto/ssh"
+)
+
+var upgrader = websocket.Upgrader{
+	CheckOrigin: func(r *http.Request) bool {
+		return true // 允许所有来源
+	},
+}
+
+// TerminalSession 终端会话
+type TerminalSession struct {
+	sshClient  *ssh.Client
+	sshSession *ssh.Session
+	stdin      io.Writer
+	stdout     io.Reader
+	wsConn     *websocket.Conn
+	done       chan struct{}
+	mu         sync.Mutex
+}
+
+// ConnectSSH 建立SSH连接并创建交互式Shell
+func ConnectSSH(host string, port int, username, password string) (*TerminalSession, error) {
+	config := &ssh.ClientConfig{
+		User:            username,
+		Auth:            []ssh.AuthMethod{ssh.Password(password)},
+		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+		Timeout:         10 * time.Second,
+		Config: ssh.Config{
+			Ciphers: []string{
+				"aes128-ctr", "aes192-ctr", "aes256-ctr",
+				"aes128-gcm@openssh.com", "aes256-gcm@openssh.com",
+				"chacha20-poly1305@openssh.com",
+				"aes128-cbc", "aes256-cbc",
+			},
+			KeyExchanges: []string{
+				"curve25519-sha256", "curve25519-sha256@libssh.org",
+				"ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
+				"diffie-hellman-group14-sha256", "diffie-hellman-group16-sha512",
+				"diffie-hellman-group14-sha1", "diffie-hellman-group1-sha1",
+			},
+		},
+	}
+
+	addr := fmt.Sprintf("%s:%d", host, port)
+	client, err := ssh.Dial("tcp", addr, config)
+	if err != nil {
+		return nil, fmt.Errorf("SSH连接失败: %w", err)
+	}
+
+	session, err := client.NewSession()
+	if err != nil {
+		client.Close()
+		return nil, fmt.Errorf("创建SSH会话失败: %w", err)
+	}
+
+	// 获取 stdin 管道
+	stdin, err := session.StdinPipe()
+	if err != nil {
+		session.Close()
+		client.Close()
+		return nil, fmt.Errorf("获取stdin失败: %w", err)
+	}
+
+	// 获取 stdout 管道
+	stdout, err := session.StdoutPipe()
+	if err != nil {
+		session.Close()
+		client.Close()
+		return nil, fmt.Errorf("获取stdout失败: %w", err)
+	}
+
+	// 也获取 stderr
+	session.Stderr = io.Discard
+
+	// 请求 PTY(xterm 终端)
+	modes := ssh.TerminalModes{
+		ssh.ECHO:          1,
+		ssh.TTY_OP_ISPEED: 14400,
+		ssh.TTY_OP_OSPEED: 14400,
+	}
+	if err := session.RequestPty("xterm", 40, 120, modes); err != nil {
+		session.Close()
+		client.Close()
+		return nil, fmt.Errorf("请求PTY失败: %w", err)
+	}
+
+	// 启动 Shell
+	if err := session.Shell(); err != nil {
+		session.Close()
+		client.Close()
+		return nil, fmt.Errorf("启动Shell失败: %w", err)
+	}
+
+	return &TerminalSession{
+		sshClient:  client,
+		sshSession: session,
+		stdin:      stdin,
+		stdout:     stdout,
+		done:       make(chan struct{}),
+	}, nil
+}
+
+// HandleTerminal 处理WebSocket终端连接
+func HandleTerminal(w http.ResponseWriter, r *http.Request, host string, port int, username, password string) {
+	// 建立 SSH 连接
+	session, err := ConnectSSH(host, port, username, password)
+	if err != nil {
+		log.Printf("[终端] SSH连接失败 %s: %v", host, err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	// 升级为 WebSocket
+	wsConn, err := upgrader.Upgrade(w, r, nil)
+	if err != nil {
+		session.Close()
+		log.Printf("[终端] WebSocket升级失败: %v", err)
+		return
+	}
+	session.wsConn = wsConn
+
+	log.Printf("[终端] 已连接到 %s (%s)", host, username)
+
+	// 启动 SSH -> WebSocket 的数据转发
+	go session.sshToWs()
+	// 启动 WebSocket -> SSH 的数据转发
+	go session.wsToSsh()
+	// 等待结束
+	<-session.done
+
+	session.Close()
+	log.Printf("[终端] 已断开 %s", host)
+}
+
+// sshToWs 从SSH读取输出并转发到WebSocket
+func (s *TerminalSession) sshToWs() {
+	buf := make([]byte, 8192)
+	for {
+		select {
+		case <-s.done:
+			return
+		default:
+		}
+
+		n, err := s.stdout.Read(buf)
+		if err != nil {
+			log.Printf("[终端] SSH读取结束: %v", err)
+			s.closeDone()
+			return
+		}
+
+		if n > 0 {
+			s.mu.Lock()
+			err := s.wsConn.WriteMessage(websocket.TextMessage, buf[:n])
+			s.mu.Unlock()
+			if err != nil {
+				log.Printf("[终端] WebSocket写入失败: %v", err)
+				s.closeDone()
+				return
+			}
+		}
+	}
+}
+
+// wsToSsh 从WebSocket读取输入并转发到SSH
+func (s *TerminalSession) wsToSsh() {
+	for {
+		select {
+		case <-s.done:
+			return
+		default:
+		}
+
+		_, message, err := s.wsConn.ReadMessage()
+		if err != nil {
+			log.Printf("[终端] WebSocket读取失败: %v", err)
+			s.closeDone()
+			return
+		}
+
+		if len(message) > 0 {
+			// 解析JSON消息格式(xterm.js发送的)
+			var msg map[string]interface{}
+			if err := json.Unmarshal(message, &msg); err == nil {
+				if input, ok := msg["input"].(string); ok {
+					_, err := s.stdin.Write([]byte(input))
+					if err != nil {
+						log.Printf("[终端] SSH写入失败: %v", err)
+						s.closeDone()
+						return
+					}
+				}
+				// 处理resize消息
+				if msg["type"] == "resize" {
+					if cols, ok := msg["cols"].(float64); ok {
+						if rows, ok := msg["rows"].(float64); ok {
+							_ = s.sshSession.WindowChange(int(rows), int(cols))
+						}
+					}
+				}
+			} else {
+				// 原始二进制数据,直接写入
+				_, err := s.stdin.Write(message)
+				if err != nil {
+					s.closeDone()
+					return
+				}
+			}
+		}
+	}
+}
+
+// closeDone 安全关闭
+func (s *TerminalSession) closeDone() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	select {
+	case <-s.done:
+		// 已经关闭
+	default:
+		close(s.done)
+	}
+}
+
+// Close 关闭会话
+func (s *TerminalSession) Close() {
+	s.closeDone()
+	if s.wsConn != nil {
+		s.wsConn.Close()
+	}
+	if s.sshSession != nil {
+		s.sshSession.Close()
+	}
+	if s.sshClient != nil {
+		s.sshClient.Close()
+	}
+}

+ 29 - 14
internal/topology/builder.go

@@ -16,8 +16,14 @@ func NewBuilder() *Builder {
 	return &Builder{}
 }
 
-// AddDevice 添加设备
+// AddDevice 添加设备(按IP去重)
 func (b *Builder) AddDevice(device models.Device) {
+	for i, existing := range b.devices {
+		if existing.IP == device.IP {
+			b.devices[i] = device // 覆盖更新
+			return
+		}
+	}
 	b.devices = append(b.devices, device)
 }
 
@@ -141,18 +147,25 @@ func (b *Builder) Build() models.TopologyGraph {
 				}
 			}
 
-			// 策略3b: 通过MAC前缀匹配(新增)
-			// 当精确MAC匹配失败时,尝试通过MAC前缀匹配(适用于同一设备的多个接口)
+			// 策略3b: 通过MAC前缀匹配(改进:排除歧义前缀)
+			// 当精确MAC匹配失败时,尝试通过MAC前缀匹配
+			// 但如果同一前缀匹配到多台设备,则跳过(避免错误连接)
 			if targetIP == "" && neighbor.RemoteMAC != "" {
 				neighborMACPrefix := getMACPrefix(neighbor.RemoteMAC)
 				fmt.Printf("    Trying MAC prefix match: %s (prefix: %s)\n", neighbor.RemoteMAC, neighborMACPrefix)
 
+				// 先统计有多少台设备匹配此MAC前缀
+				type prefixMatch struct {
+					ip           string
+					matchingMACs int
+				}
+				var matches []prefixMatch
+
 				for _, d := range b.devices {
 					if d.IP == device.IP {
 						continue // 跳过自己
 					}
 
-					// 检查该设备的MAC地址是否有相同前缀
 					matchingMACs := 0
 					for _, mac := range d.MACAddresses {
 						if getMACPrefix(mac) == neighborMACPrefix {
@@ -160,18 +173,20 @@ func (b *Builder) Build() models.TopologyGraph {
 						}
 					}
 
-					// 如果该设备有多个MAC地址使用相同前缀,说明是同一台设备
-					if matchingMACs >= 3 { // 至少3个MAC使用相同前缀
-						// 进一步验证:检查是否在同一网段
-						if getSubnet(d.IP) == getSubnet(device.IP) {
-							targetIP = d.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, matchingMACs, neighborMACPrefix, d.IP)
-							break
-						}
+					if matchingMACs >= 3 {
+						matches = append(matches, prefixMatch{ip: d.IP, matchingMACs: matchingMACs})
 					}
 				}
+
+				// 只在唯一匹配时使用前缀匹配
+				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)
+				}
 			}
 
 			// 策略4: 通过本地接口IP网段匹配(新增)

+ 22 - 9
pkg/models/models.go

@@ -59,15 +59,15 @@ type Neighbor struct {
 
 // ScanTask 扫描任务
 type ScanTask struct {
-	ID          string    `json:"id"`
-	Status      string    `json:"status"` // running, completed, failed
-	Progress    int       `json:"progress"`
-	TotalDevices int      `json:"total_devices"`
-	ScannedDevices int    `json:"scanned_devices"`
-	StartTime   time.Time `json:"start_time"`
-	EndTime     time.Time `json:"end_time"`
-	Devices     []Device  `json:"devices"`
-	ErrorMessage string   `json:"error_message,omitempty"`
+	ID             string    `json:"id"`
+	Status         string    `json:"status"` // running, completed, failed
+	Progress       int       `json:"progress"`
+	TotalDevices   int       `json:"total_devices"`
+	ScannedDevices int       `json:"scanned_devices"`
+	StartTime      time.Time `json:"start_time"`
+	EndTime        time.Time `json:"end_time"`
+	Devices        []Device  `json:"devices"`
+	ErrorMessage   string    `json:"error_message,omitempty"`
 }
 
 // TopologyGraph 拓扑图数据
@@ -94,3 +94,16 @@ type TopologyEdge struct {
 	TargetInterface string `json:"target_interface"`
 	Protocol        string `json:"protocol"`
 }
+
+// Topology 网络拓扑(一个拓扑包含多个设备)
+type Topology struct {
+	ID          string    `json:"id"`
+	Name        string    `json:"name"`
+	Description string    `json:"description"`
+	ScanRange   string    `json:"scan_range"`
+	SSHPort     int       `json:"ssh_port"`
+	Username    string    `json:"username"`
+	CreatedAt   time.Time `json:"created_at"`
+	UpdatedAt   time.Time `json:"updated_at"`
+	DeviceCount int       `json:"device_count"`
+}

+ 12 - 0
test-fix.bat

@@ -0,0 +1,12 @@
+@echo off
+echo Building network topology discovery system...
+go build -o network-topology.exe ./cmd
+if %ERRORLEVEL% NEQ 0 (
+    echo Build failed!
+    pause
+    exit /b 1
+)
+echo Build successful!
+echo.
+echo Starting the application...
+network-topology.exe

+ 119 - 1
web/css/style.css

@@ -33,6 +33,24 @@ header h1 {
 .controls {
     display: flex;
     gap: 10px;
+    align-items: center;
+}
+
+.topology-selector {
+    padding: 10px 15px;
+    border: none;
+    border-radius: 5px;
+    font-size: 14px;
+    background: rgba(255, 255, 255, 0.9);
+    color: #333;
+    min-width: 200px;
+    cursor: pointer;
+    transition: all 0.3s;
+}
+
+.topology-selector:hover {
+    background: white;
+    box-shadow: 0 4px 8px rgba(0,0,0,0.2);
 }
 
 .btn {
@@ -171,7 +189,7 @@ header h1 {
 }
 
 .detail-panel {
-    width: 350px;
+    width: 600px;
     background: white;
     padding: 20px;
     overflow-y: auto;
@@ -269,3 +287,103 @@ header h1 {
     color: #FF9800;
     font-weight: bold;
 }
+
+/* SSH终端样式 */
+.terminal-modal-content {
+    width: 800px;
+    max-width: 90vw;
+}
+
+.terminal-container {
+    width: 100%;
+    height: 500px;
+    background: #1e1e1e;
+    border-radius: 5px;
+    overflow: hidden;
+    margin-top: 15px;
+}
+
+.terminal-status {
+    margin-top: 10px;
+    font-size: 12px;
+    color: #999;
+    text-align: right;
+}
+
+.terminal-status.connected {
+    color: #4CAF50;
+}
+
+/* 设备选择器样式 */
+.select-devices-modal-content {
+    width: 600px;
+    max-width: 90vw;
+}
+
+.select-devices-toolbar {
+    display: flex;
+    align-items: center;
+    gap: 15px;
+    margin-bottom: 15px;
+    padding-bottom: 10px;
+    border-bottom: 1px solid #eee;
+}
+
+.btn-sm {
+    padding: 6px 14px;
+    font-size: 13px;
+}
+
+.selected-count {
+    font-size: 13px;
+    color: #666;
+}
+
+.device-pool-list {
+    max-height: 400px;
+    overflow-y: auto;
+}
+
+.device-pool-item {
+    display: flex;
+    align-items: center;
+    padding: 10px;
+    margin-bottom: 8px;
+    background: #f9f9f9;
+    border-radius: 5px;
+    cursor: pointer;
+    transition: all 0.2s;
+    border-left: 3px solid #ddd;
+}
+
+.device-pool-item:hover {
+    background: #f0f0f0;
+    border-left-color: #667eea;
+}
+
+.device-pool-item.selected {
+    background: #e8f5e9;
+    border-left-color: #4CAF50;
+}
+
+.device-pool-item input[type="checkbox"] {
+    margin-right: 12px;
+    width: 18px;
+    height: 18px;
+    cursor: pointer;
+}
+
+.device-pool-item .device-info {
+    flex: 1;
+}
+
+.device-pool-item .device-ip {
+    font-weight: bold;
+    color: #667eea;
+}
+
+.device-pool-item .device-type {
+    font-size: 12px;
+    color: #666;
+    margin-top: 3px;
+}

+ 74 - 0
web/index.html

@@ -6,12 +6,20 @@
     <title>网络拓扑发现系统</title>
     <link rel="stylesheet" href="/css/style.css">
     <script src="https://unpkg.com/cytoscape@3.26.0/dist/cytoscape.min.js"></script>
+    <link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css">
+    <script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
+    <script src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
+    <script src="https://unpkg.com/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
 </head>
 <body>
     <div class="container">
         <header>
             <h1>🌐 网络拓扑发现系统</h1>
             <div class="controls">
+                <select id="topology-selector" class="topology-selector">
+                    <option value="">加载中...</option>
+                </select>
+                <button id="btn-new-topology" class="btn btn-info">新建拓扑</button>
                 <button id="btn-scan" class="btn btn-primary">开始扫描</button>
                 <button id="btn-add-device" class="btn btn-success">添加设备</button>
                 <button id="btn-export" class="btn btn-info">导出拓扑</button>
@@ -73,6 +81,43 @@
         </div>
     </div>
 
+    <!-- SSH终端模态框 -->
+    <div id="modal-terminal" class="modal">
+        <div class="modal-content terminal-modal-content">
+            <span class="close-terminal">&times;</span>
+            <h2>SSH 终端 - <span id="terminal-device-name"></span></h2>
+            <div id="terminal-container" class="terminal-container"></div>
+            <div id="terminal-status" class="terminal-status">已断开</div>
+        </div>
+    </div>
+
+    <!-- SSH凭据输入弹窗 -->
+    <div id="modal-ssh-creds" class="modal">
+        <div class="modal-content">
+            <span class="close-ssh-creds">&times;</span>
+            <h2>SSH 连接凭据</h2>
+            <form id="ssh-creds-form">
+                <div class="form-group">
+                    <label>设备IP:</label>
+                    <input type="text" id="ssh-target-ip" readonly>
+                </div>
+                <div class="form-group">
+                    <label for="ssh-target-port">SSH端口:</label>
+                    <input type="number" id="ssh-target-port" value="22">
+                </div>
+                <div class="form-group">
+                    <label for="ssh-target-username">用户名:</label>
+                    <input type="text" id="ssh-target-username" required placeholder="admin">
+                </div>
+                <div class="form-group">
+                    <label for="ssh-target-password">密码:</label>
+                    <input type="password" id="ssh-target-password" required>
+                </div>
+                <button type="submit" class="btn btn-primary">连接</button>
+            </form>
+        </div>
+    </div>
+
     <!-- 添加设备模态框 -->
     <div id="modal" class="modal">
         <div class="modal-content">
@@ -107,6 +152,35 @@
         </div>
     </div>
 
+    <!-- 新建拓扑模态框 -->
+    <div id="modal-new-topology" class="modal">
+        <div class="modal-content">
+            <span class="close-new-topology">&times;</span>
+            <h2>新建拓扑</h2>
+            <form id="new-topology-form">
+                <div class="form-group">
+                    <label for="topo-name">拓扑名称:</label>
+                    <input type="text" id="topo-name" required placeholder="例: 北京办公网络">
+                </div>
+                <button type="submit" class="btn btn-primary" style="width:100%">创建</button>
+            </form>
+        </div>
+    </div>
+
+    <!-- 选择设备模态框 -->
+    <div id="modal-select-devices" class="modal">
+        <div class="modal-content select-devices-modal-content">
+            <span class="close-select-devices">&times;</span>
+            <h2>选择设备添加到拓扑</h2>
+            <div class="select-devices-toolbar">
+                <button type="button" class="btn btn-info btn-sm" onclick="toggleSelectAllDevices(this)">全选</button>
+                <span id="selected-count" class="selected-count">已选 0 台</span>
+            </div>
+            <div id="device-pool-list" class="device-pool-list"></div>
+            <button type="button" class="btn btn-primary" style="width:100%;margin-top:15px" onclick="confirmAddDevices()">添加选中设备</button>
+        </div>
+    </div>
+
     <script src="/js/app.js"></script>
 </body>
 </html>

+ 399 - 4
web/js/app.js

@@ -1,11 +1,14 @@
 // 全局变量
 let cy = null;
 let currentTaskId = null;
+let currentTopologyId = null;
+let currentTerminal = null; // 当前终端实例
 
 // 初始化
 document.addEventListener('DOMContentLoaded', function() {
     initCytoscape();
     initEventListeners();
+    loadTopologyList(); // 加载拓扑列表
     loadTopology();
     loadDeviceList(); // 加载设备列表
 });
@@ -40,8 +43,6 @@ function initCytoscape() {
                 style: {
                     'width': 2,
                     'line-color': '#999',
-                    'target-arrow-color': '#999',
-                    'target-arrow-shape': 'triangle',
                     'curve-style': 'bezier',
                     'label': 'data(protocol)'
                 }
@@ -84,6 +85,22 @@ function getNodeColor(type) {
 
 // 初始化事件监听
 function initEventListeners() {
+    // 拓扑选择器
+    document.getElementById('topology-selector').addEventListener('change', switchTopology);
+    
+    // 新建拓扑按钮
+    document.getElementById('btn-new-topology').addEventListener('click', function() {
+        document.getElementById('modal-new-topology').classList.add('active');
+    });
+    
+    // 关闭新建拓扑模态框
+    document.querySelector('.close-new-topology').addEventListener('click', function() {
+        document.getElementById('modal-new-topology').classList.remove('active');
+    });
+    
+    // 新建拓扑表单
+    document.getElementById('new-topology-form').addEventListener('submit', createTopology);
+    
     // 扫描按钮
     document.getElementById('btn-scan').addEventListener('click', startScan);
     
@@ -102,6 +119,18 @@ function initEventListeners() {
     
     // 导出按钮
     document.getElementById('btn-export').addEventListener('click', exportTopology);
+
+    // SSH终端相关
+    document.querySelector('.close-terminal').addEventListener('click', closeTerminal);
+    document.querySelector('.close-ssh-creds').addEventListener('click', function() {
+        document.getElementById('modal-ssh-creds').classList.remove('active');
+    });
+    document.getElementById('ssh-creds-form').addEventListener('submit', connectTerminal);
+
+    // 设备选择器相关
+    document.querySelector('.close-select-devices').addEventListener('click', function() {
+        document.getElementById('modal-select-devices').classList.remove('active');
+    });
 }
 
 // 开始扫描
@@ -145,18 +174,27 @@ async function startScan() {
 async function pollProgress() {
     if (!currentTaskId) return;
 
+    let failCount = 0;
+    const MAX_FAILS = 5;
+
     const poll = async () => {
         try {
             const response = await fetch(`/api/scan/${currentTaskId}`);
+            if (!response.ok) {
+                throw new Error(`HTTP ${response.status}`);
+            }
             const task = await response.json();
+            failCount = 0; // 重置失败计数
 
             // 更新进度
             document.getElementById('scan-status').textContent = task.status;
             document.getElementById('scan-progress').textContent = task.progress + '%';
             document.getElementById('progress-fill').style.width = task.progress + '%';
 
-            // 更新设备列表
-            updateDeviceList(task.devices);
+            // 更新设备列表(仅当有设备时)
+            if (task.devices && task.devices.length > 0) {
+                updateDeviceList(task.devices);
+            }
 
             // 如果完成,更新拓扑
             if (task.status === 'completed' || task.status === 'failed') {
@@ -169,7 +207,16 @@ async function pollProgress() {
             // 继续轮询
             setTimeout(poll, 1000);
         } catch (error) {
+            failCount++;
             console.error('获取进度失败:', error);
+            // 超过最大失败次数则停止轮询
+            if (failCount >= MAX_FAILS) {
+                document.getElementById('scan-status').textContent = 'error';
+                currentTaskId = null;
+                return;
+            }
+            // 等待更长时间后重试
+            setTimeout(poll, 2000);
         }
     };
 
@@ -304,6 +351,7 @@ async function showDeviceDetail(deviceId) {
                 <p><strong>类型:</strong> ${device.type}</p>
                 <p><strong>系统:</strong> ${device.os_version || 'N/A'}</p>
                 <p><strong>运行时间:</strong> ${device.uptime || 'N/A'}</p>
+                <button class="btn btn-primary" style="margin-top:10px;width:100%" onclick="openSSHTerminal('${device.ip}', '${device.hostname || device.ip}')">SSH 连接</button>
             </div>
             <div class="detail-section">
                 <h4>接口信息 (${device.interfaces.length})</h4>
@@ -390,3 +438,350 @@ function exportTopology() {
     link.download = 'topology.json';
     link.click();
 }
+
+// 加载拓扑列表
+async function loadTopologyList() {
+    try {
+        const response = await fetch('/api/topologies');
+        const topos = await response.json();
+
+        const selector = document.getElementById('topology-selector');
+        selector.innerHTML = '';
+
+        if (topos.length === 0) {
+            selector.innerHTML = '<option value="">暂无拓扑</option>';
+            return;
+        }
+
+        topos.forEach(topo => {
+            const option = document.createElement('option');
+            option.value = topo.id;
+            option.textContent = `${topo.name} (${topo.device_count || 0} 设备)`;
+            if (topo.name.includes(' (当前)')) {
+                option.selected = true;
+                currentTopologyId = topo.id;
+            }
+            selector.appendChild(option);
+        });
+    } catch (error) {
+        console.error('加载拓扑列表失败:', error);
+    }
+}
+
+// 切换拓扑
+async function switchTopology(event) {
+    const topoId = event.target.value;
+    if (!topoId) return;
+
+    try {
+        const response = await fetch('/api/topology/switch', {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/json'
+            },
+            body: JSON.stringify({
+                topology_id: topoId
+            })
+        });
+
+        if (response.ok) {
+            currentTopologyId = topoId;
+            // 刷新拓扑和设备列表
+            loadTopology();
+            loadDeviceList();
+        } else {
+            const error = await response.json();
+            alert('切换失败: ' + error);
+        }
+    } catch (error) {
+        console.error('切换拓扑失败:', error);
+        alert('切换失败: ' + error.message);
+    }
+}
+
+// 新创建的拓扑ID(用于设备选择器)
+let newTopologyId = null;
+
+// 创建拓扑
+async function createTopology(event) {
+    event.preventDefault();
+
+    const name = document.getElementById('topo-name').value;
+
+    try {
+        const response = await fetch('/api/topologies', {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/json'
+            },
+            body: JSON.stringify({ name: name })
+        });
+
+        if (response.ok) {
+            const topo = await response.json();
+            newTopologyId = topo.id;
+
+            document.getElementById('modal-new-topology').classList.remove('active');
+            document.getElementById('new-topology-form').reset();
+
+            // 刷新拓扑列表
+            loadTopologyList();
+            loadTopology();
+            loadDeviceList();
+
+            // 弹出设备选择器
+            openDeviceSelector();
+        } else {
+            const error = await response.json();
+            alert('创建失败: ' + error);
+        }
+    } catch (error) {
+        console.error('创建拓扑失败:', error);
+        alert('创建失败: ' + error.message);
+    }
+}
+
+// ==================== 设备选择器 ====================
+
+// 打开设备选择器
+async function openDeviceSelector() {
+    document.getElementById('modal-select-devices').classList.add('active');
+    document.getElementById('selected-count').textContent = '已选 0 台';
+
+    const poolList = document.getElementById('device-pool-list');
+    poolList.innerHTML = '<p style="color:#999;text-align:center">加载中...</p>';
+
+    try {
+        const response = await fetch('/api/devices/all');
+        const devices = await response.json();
+
+        poolList.innerHTML = '';
+
+        if (devices.length === 0) {
+            poolList.innerHTML = '<p style="color:#999;text-align:center">暂无设备,请先扫描添加设备</p>';
+            return;
+        }
+
+        devices.forEach(device => {
+            const item = document.createElement('div');
+            item.className = 'device-pool-item';
+            item.innerHTML = `
+                <input type="checkbox" value="${device.ip}" onchange="updateSelectedCount()">
+                <div class="device-info">
+                    <div class="device-ip">${device.ip}</div>
+                    <div class="device-type">${device.type} - ${device.hostname || 'Unknown'}</div>
+                </div>
+            `;
+            // 点击整行切换选中
+            item.addEventListener('click', function(e) {
+                if (e.target.tagName !== 'INPUT') {
+                    const cb = item.querySelector('input[type=checkbox]');
+                    cb.checked = !cb.checked;
+                    updateSelectedCount();
+                }
+                item.classList.toggle('selected', item.querySelector('input[type=checkbox]').checked);
+            });
+            poolList.appendChild(item);
+        });
+    } catch (error) {
+        poolList.innerHTML = '<p style="color:#f44336;text-align:center">加载失败</p>';
+        console.error('加载设备池失败:', error);
+    }
+}
+
+// 更新选中计数
+function updateSelectedCount() {
+    const checkboxes = document.querySelectorAll('#device-pool-list input[type=checkbox]:checked');
+    document.getElementById('selected-count').textContent = `已选 ${checkboxes.length} 台`;
+}
+
+// 全选/取消全选
+function toggleSelectAllDevices(btn) {
+    const checkboxes = document.querySelectorAll('#device-pool-list input[type=checkbox]');
+    const allChecked = Array.from(checkboxes).every(cb => cb.checked);
+    checkboxes.forEach(cb => {
+        cb.checked = !allChecked;
+        cb.closest('.device-pool-item').classList.toggle('selected', !allChecked);
+    });
+    btn.textContent = allChecked ? '全选' : '取消全选';
+    updateSelectedCount();
+}
+
+// 确认添加设备到拓扑
+async function confirmAddDevices() {
+    const targetTopoId = newTopologyId || currentTopologyId;
+    if (!targetTopoId) {
+        alert('请先选择目标拓扑');
+        return;
+    }
+
+    const checkboxes = document.querySelectorAll('#device-pool-list input[type=checkbox]:checked');
+    if (checkboxes.length === 0) {
+        alert('请至少选择一台设备');
+        return;
+    }
+
+    const deviceIds = Array.from(checkboxes).map(cb => cb.value);
+
+    try {
+        const response = await fetch(`/api/topology/${targetTopoId}/devices`, {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ device_ids: deviceIds })
+        });
+
+        if (response.ok) {
+            const result = await response.json();
+            document.getElementById('modal-select-devices').classList.remove('active');
+            newTopologyId = null;
+
+            // 刷新界面
+            loadTopologyList();
+            loadTopology();
+            loadDeviceList();
+
+            alert(`成功添加 ${result.added} 台设备到 ${result.topology}`);
+        } else {
+            alert('添加失败');
+        }
+    } catch (error) {
+        console.error('添加设备失败:', error);
+        alert('添加失败: ' + error.message);
+    }
+}
+
+// ==================== SSH终端功能 ====================
+
+// 打开SSH终端(先弹出凭据输入框)
+function openSSHTerminal(ip, hostname) {
+    document.getElementById('ssh-target-ip').value = ip;
+    document.getElementById('ssh-target-username').value = document.getElementById('username').value || 'admin';
+    document.getElementById('ssh-target-password').value = document.getElementById('password').value || '';
+    document.getElementById('ssh-target-port').value = document.getElementById('ssh-port').value || '22';
+    document.getElementById('modal-ssh-creds').classList.add('active');
+}
+
+// 连接SSH终端
+async function connectTerminal(event) {
+    event.preventDefault();
+
+    const ip = document.getElementById('ssh-target-ip').value;
+    const port = document.getElementById('ssh-target-port').value || '22';
+    const username = document.getElementById('ssh-target-username').value;
+    const password = document.getElementById('ssh-target-password').value;
+
+    if (!username || !password) {
+        alert('请输入用户名和密码');
+        return;
+    }
+
+    // 关闭凭据弹窗,打开终端弹窗
+    document.getElementById('modal-ssh-creds').classList.remove('active');
+    document.getElementById('modal-terminal').classList.add('active');
+    document.getElementById('terminal-device-name').textContent = ip;
+    document.getElementById('terminal-status').textContent = '连接中...';
+    document.getElementById('terminal-status').className = 'terminal-status';
+
+    // 初始化 xterm.js
+    if (currentTerminal) {
+        currentTerminal.dispose();
+    }
+
+    const term = new Terminal({
+        cursorBlink: true,
+        fontSize: 14,
+        fontFamily: 'Consolas, "Courier New", monospace',
+        theme: {
+            background: '#1e1e1e',
+            foreground: '#d4d4d4',
+            cursor: '#d4d4d4'
+        }
+    });
+
+    const fitAddon = new FitAddon.FitAddon();
+    term.loadAddon(fitAddon);
+
+    const terminalContainer = document.getElementById('terminal-container');
+    terminalContainer.innerHTML = '';
+    term.open(terminalContainer);
+    fitAddon.fit();
+
+    currentTerminal = term;
+
+    // 构建WebSocket URL
+    const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+    const wsUrl = `${wsProtocol}//${window.location.host}/api/terminal?ip=${encodeURIComponent(ip)}&port=${encodeURIComponent(port)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`;
+
+    let ws;
+    try {
+        ws = new WebSocket(wsUrl);
+    } catch (e) {
+        term.writeln('连接失败: ' + e.message);
+        document.getElementById('terminal-status').textContent = '连接失败';
+        return;
+    }
+
+    ws.onopen = function() {
+        document.getElementById('terminal-status').textContent = '已连接';
+        document.getElementById('terminal-status').className = 'terminal-status connected';
+        term.writeln('\x1b[32m--- SSH 连接已建立 ---\x1b[0m');
+        term.focus();
+        fitAddon.fit();
+    };
+
+    ws.onmessage = function(event) {
+        term.write(event.data);
+    };
+
+    ws.onclose = function() {
+        document.getElementById('terminal-status').textContent = '已断开';
+        document.getElementById('terminal-status').className = 'terminal-status';
+        term.writeln('\r\n\x1b[31m--- 连接已断开 ---\x1b[0m');
+    };
+
+    ws.onerror = function(err) {
+        document.getElementById('terminal-status').textContent = '连接错误';
+        document.getElementById('terminal-status').className = 'terminal-status';
+        term.writeln('\r\n\x1b[31m--- 连接错误 ---\x1b[0m');
+    };
+
+    // 终端输入转发到WebSocket
+    term.onData(function(data) {
+        if (ws && ws.readyState === WebSocket.OPEN) {
+            ws.send(JSON.stringify({ input: data }));
+        }
+    });
+
+    // 终端resize通知
+    term.onResize(function(size) {
+        if (ws && ws.readyState === WebSocket.OPEN) {
+            ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
+        }
+    });
+
+    // 窗口resize时自动调整
+    const resizeObserver = new ResizeObserver(function() {
+        fitAddon.fit();
+    });
+    resizeObserver.observe(terminalContainer);
+
+    // 存储ws引用以便关闭
+    term._ws = ws;
+    term._resizeObserver = resizeObserver;
+}
+
+// 关闭终端
+function closeTerminal() {
+    if (currentTerminal) {
+        if (currentTerminal._ws) {
+            currentTerminal._ws.close();
+        }
+        if (currentTerminal._resizeObserver) {
+            currentTerminal._resizeObserver.disconnect();
+        }
+        currentTerminal.dispose();
+        currentTerminal = null;
+    }
+    document.getElementById('modal-terminal').classList.remove('active');
+    document.getElementById('terminal-container').innerHTML = '';
+}