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

- 修复扫描进度条不动的问题(分4阶段更新进度)
- 新增Web SSH远程终端(xterm.js + WebSocket)
- 新增多拓扑管理(创建/切换拓扑、全局设备池)
- 简化新建拓扑流程(仅需名称,创建后选择设备)
- 修复拓扑Builder设备去重(按IP去重)
- 修复启动时拓扑设备不加载到Builder的问题
- 优化MAC前缀匹配(避免歧义前缀导致错误连线)
- 拓扑连线改为无向(去除箭头)
- 设备详情面板加宽到600px
This commit is contained in:
Your Name
2026-04-26 13:25:19 +08:00
parent 6e1b010c17
commit 44f7fef1f8
17 changed files with 1940 additions and 54 deletions
+3
View File
@@ -23,3 +23,6 @@ config.json
# 临时文件
*.tmp
*.bak
# 运行时数据
data/
+69
View File
@@ -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
View File
@@ -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
View File
@@ -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处理问题**
- **保持简单有效的策略**
+394 -7
View File
@@ -15,6 +15,7 @@ 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"
)
@@ -24,6 +25,7 @@ type App struct {
config *config.Config
builder *topology.Builder
storage *storage.Storage
topologyStorage *storage.TopologyStorage
tasks map[string]*models.ScanTask
mu sync.RWMutex
httpServer *http.Server
@@ -31,7 +33,13 @@ type App struct {
// 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)
@@ -41,10 +49,22 @@ func NewApp(cfg *config.Config) *App {
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,
@@ -217,15 +281,16 @@ 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))
}
// 处理扫描进度查询
@@ -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"
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
// 请求 PTYxterm 终端)
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()
}
}
+28 -13
View File
@@ -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,17 +173,19 @@ func (b *Builder) Build() models.TopologyGraph {
}
}
// 如果该设备有多个MAC地址使用相同前缀,说明是同一台设备
if matchingMACs >= 3 { // 至少3个MAC使用相同前缀
// 进一步验证:检查是否在同一网段
if getSubnet(d.IP) == getSubnet(device.IP) {
targetIP = d.IP
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, matchingMACs, neighborMACPrefix, d.IP)
break
}
}
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)
}
}
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+398 -3
View File
@@ -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 + '%';
// 更新设备列表
// 更新设备列表(仅当有设备时)
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 = '';
}