Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7e51c5bea | |||
| 07adc3ac5c | |||
| 606e29a53c | |||
| 2a97f458a9 | |||
| bcad5b8e27 | |||
| 44f7fef1f8 |
@@ -23,3 +23,6 @@ config.json
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# 运行时数据
|
||||
data/
|
||||
|
||||
@@ -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数据包
|
||||
@@ -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. **兼容性检测**:自动检测设备类型,使用正确的命令集
|
||||
@@ -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处理问题**
|
||||
- **保持简单有效的策略**
|
||||
+620
-32
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -15,36 +16,59 @@ 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
|
||||
sessions map[string]time.Time // token -> expire time
|
||||
sessionMu sync.RWMutex
|
||||
}
|
||||
|
||||
// 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),
|
||||
sessions: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// 从数据库加载设备到拓扑构建器
|
||||
// 如果有拓扑存储,切换到第一个拓扑
|
||||
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 {
|
||||
@@ -65,24 +89,56 @@ func (app *App) Start() error {
|
||||
// 设置路由
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// 静态文件服务 - 使用文件系统而非embed
|
||||
// 登录API(无需认证)
|
||||
mux.HandleFunc("/api/login", app.handleLogin)
|
||||
mux.HandleFunc("/api/logout", app.handleLogout)
|
||||
|
||||
// 登录页面(无需认证)
|
||||
mux.HandleFunc("/login", app.handleLoginPage)
|
||||
|
||||
// 认证中间件保护的路由
|
||||
authMux := http.NewServeMux()
|
||||
|
||||
// 静态文件服务
|
||||
webDir := getWebDir()
|
||||
if _, err := os.Stat(webDir); err == nil {
|
||||
mux.Handle("/", http.FileServer(http.Dir(webDir)))
|
||||
authMux.Handle("/", http.FileServer(http.Dir(webDir)))
|
||||
} else {
|
||||
log.Printf("警告: web目录不存在,静态文件服务不可用")
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
authMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("<h1>网络拓扑发现系统</h1><p>Web界面文件未找到</p>"))
|
||||
})
|
||||
}
|
||||
|
||||
// API路由
|
||||
mux.HandleFunc("/api/scan", app.handleScan)
|
||||
mux.HandleFunc("/api/scan/{id}", app.handleScanProgress)
|
||||
mux.HandleFunc("/api/topology", app.handleTopology)
|
||||
mux.HandleFunc("/api/devices", app.handleGetDevices)
|
||||
mux.HandleFunc("/api/device", app.handleAddDevice)
|
||||
mux.HandleFunc("/api/device/{id}", app.handleDeviceDetail)
|
||||
authMux.HandleFunc("/api/scan", app.handleScan)
|
||||
authMux.HandleFunc("/api/scan/{id}", app.handleScanProgress)
|
||||
authMux.HandleFunc("/api/topology", app.handleTopology)
|
||||
authMux.HandleFunc("/api/devices", app.handleGetDevices)
|
||||
authMux.HandleFunc("/api/device", app.handleAddDevice)
|
||||
authMux.HandleFunc("/api/device/{id}", app.handleDeviceDetail)
|
||||
|
||||
// 拓扑管理API
|
||||
authMux.HandleFunc("/api/topologies", app.handleTopologies)
|
||||
authMux.HandleFunc("/api/topology/switch", app.handleSwitchTopology)
|
||||
authMux.HandleFunc("/api/topology/{id}", app.handleTopologyDetail)
|
||||
|
||||
// SSH终端API
|
||||
authMux.HandleFunc("/api/terminal", app.handleTerminalConnect)
|
||||
|
||||
// 全局设备池API
|
||||
authMux.HandleFunc("/api/devices/all", app.handleGetAllDevices)
|
||||
authMux.HandleFunc("/api/topology/{id}/devices", app.handleAddDevicesToTopology)
|
||||
|
||||
// 根据认证配置决定是否启用中间件
|
||||
var handler http.Handler = authMux
|
||||
if app.config.Auth.Enabled {
|
||||
handler = app.authMiddleware(authMux)
|
||||
log.Printf("认证已启用, 用户: %s", app.config.Auth.Username)
|
||||
} else {
|
||||
log.Printf("认证未启用")
|
||||
}
|
||||
mux.Handle("/", handler)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", app.config.Web.Host, app.config.Web.Port)
|
||||
app.httpServer = &http.Server{
|
||||
@@ -157,6 +213,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 +239,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 +296,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 +305,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 +345,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 +386,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 +396,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)
|
||||
@@ -329,6 +418,64 @@ func (app *App) handleAddDevice(w http.ResponseWriter, r *http.Request) {
|
||||
func (app *App) handleDeviceDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
|
||||
if r.Method == http.MethodDelete {
|
||||
// 删除设备
|
||||
if app.storage == nil {
|
||||
http.Error(w, "Storage not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := app.storage.DeleteDevice(id); err != nil {
|
||||
log.Printf("Failed to delete device %s: %v", id, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// 同时从 builder 中移除
|
||||
app.builder.RemoveDevice(id)
|
||||
log.Printf("Deleted device: %s", id)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPut {
|
||||
// 修改设备信息(Hostname、Type)
|
||||
var req struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if app.storage == nil {
|
||||
http.Error(w, "Storage not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dev, err := app.storage.GetDevice(id)
|
||||
if err != nil {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if req.Hostname != "" {
|
||||
dev.Hostname = req.Hostname
|
||||
}
|
||||
if req.Type != "" {
|
||||
dev.Type = models.DeviceType(req.Type)
|
||||
}
|
||||
if err := app.storage.SaveDevice(dev); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// 同时更新 builder 中的设备
|
||||
app.builder.AddDevice(*dev)
|
||||
log.Printf("Updated device: %s", id)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(dev)
|
||||
return
|
||||
}
|
||||
|
||||
// GET: 获取设备详情
|
||||
devices := app.builder.GetDevices()
|
||||
for _, dev := range devices {
|
||||
if dev.ID == id || dev.IP == id {
|
||||
@@ -341,6 +488,447 @@ 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)
|
||||
}
|
||||
|
||||
// ==================== 认证相关 ====================
|
||||
|
||||
// authMiddleware 认证中间件
|
||||
func (app *App) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 从 cookie 获取 token
|
||||
cookie, err := r.Cookie("session_token")
|
||||
if err != nil || !app.isValidSession(cookie.Value) {
|
||||
// API 请求返回 401,页面请求重定向到登录
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
http.Error(w, `{"error": "Unauthorized"}`, http.StatusUnauthorized)
|
||||
} else {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// isValidSession 验证会话是否有效
|
||||
func (app *App) isValidSession(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
app.sessionMu.RLock()
|
||||
expire, exists := app.sessions[token]
|
||||
app.sessionMu.RUnlock()
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(expire) {
|
||||
app.sessionMu.Lock()
|
||||
delete(app.sessions, token)
|
||||
app.sessionMu.Unlock()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// generateToken 生成随机 token
|
||||
func generateToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
// handleLogin 处理登录
|
||||
func (app *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !app.config.Auth.Enabled {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"success": true})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Username == app.config.Auth.Username && req.Password == app.config.Auth.Password {
|
||||
token := generateToken()
|
||||
expire := time.Now().Add(24 * time.Hour)
|
||||
|
||||
app.sessionMu.Lock()
|
||||
app.sessions[token] = expire
|
||||
app.sessionMu.Unlock()
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expire,
|
||||
HttpOnly: true,
|
||||
})
|
||||
|
||||
log.Printf("用户 %s 登录成功", req.Username)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"success": true})
|
||||
} else {
|
||||
log.Printf("登录失败: 用户名或密码错误 (username=%s)", req.Username)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "用户名或密码错误"})
|
||||
}
|
||||
}
|
||||
|
||||
// handleLogout 处理登出
|
||||
func (app *App) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie("session_token"); err == nil {
|
||||
app.sessionMu.Lock()
|
||||
delete(app.sessions, cookie.Value)
|
||||
app.sessionMu.Unlock()
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
// handleLoginPage 返回登录页面
|
||||
func (app *App) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !app.config.Auth.Enabled {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join(getWebDir(), "login.html"))
|
||||
}
|
||||
|
||||
// 获取所有拓扑中的全部设备(全局设备池)
|
||||
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 +950,7 @@ func main() {
|
||||
|
||||
// 创建并启动应用
|
||||
app := NewApp(cfg)
|
||||
|
||||
|
||||
log.Println("网络拓扑发现系统启动...")
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatalf("服务启动失败: %v", err)
|
||||
|
||||
@@ -30,5 +30,10 @@
|
||||
"scanner": {
|
||||
"concurrency": 10,
|
||||
"timeout": 2
|
||||
},
|
||||
"auth": {
|
||||
"enabled": true,
|
||||
"username": "admin",
|
||||
"password": "admin123"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -3,8 +3,8 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"network-topology-discovery/pkg/models"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config 应用配置
|
||||
@@ -14,16 +14,17 @@ type Config struct {
|
||||
SSH SSHConfig `json:"ssh"`
|
||||
Web WebConfig `json:"web"`
|
||||
Scanner ScannerConfig `json:"scanner"`
|
||||
Auth AuthConfig `json:"auth"`
|
||||
}
|
||||
|
||||
// DeviceConfig 设备配置
|
||||
type DeviceConfig struct {
|
||||
IP string `json:"ip"`
|
||||
IP string `json:"ip"`
|
||||
Type models.DeviceType `json:"type"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
KeyFile string `json:"key_file"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
KeyFile string `json:"key_file"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
// SSHConfig SSH配置
|
||||
@@ -39,6 +40,13 @@ type WebConfig struct {
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
// AuthConfig 认证配置
|
||||
type AuthConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// ScannerConfig 扫描器配置
|
||||
type ScannerConfig struct {
|
||||
Concurrency int `json:"concurrency"`
|
||||
@@ -122,4 +130,10 @@ func (c *Config) setDefaults() {
|
||||
if c.Scanner.Timeout == 0 {
|
||||
c.Scanner.Timeout = 2
|
||||
}
|
||||
if c.Auth.Enabled && c.Auth.Username == "" {
|
||||
c.Auth.Username = "admin"
|
||||
}
|
||||
if c.Auth.Enabled && c.Auth.Password == "" {
|
||||
c.Auth.Password = "admin"
|
||||
}
|
||||
}
|
||||
|
||||
+197
-15
@@ -37,16 +37,27 @@ func (p *H3CParser) Parse(device *models.Device, outputs []string) error {
|
||||
// outputs[2] 是 display interface
|
||||
fmt.Printf("[H3C DEBUG] display interface output length: %d\n", len(outputs[2]))
|
||||
if len(outputs[2]) > 0 {
|
||||
// 如果输出小于1000字节,完整输出以便调试
|
||||
if len(outputs[2]) <= 1000 {
|
||||
fmt.Printf("[H3C DEBUG] Complete output:\n%s\n", outputs[2])
|
||||
if len(outputs[2]) <= 3000 {
|
||||
fmt.Printf("[H3C DEBUG] Complete interface output:\n%s\n", outputs[2])
|
||||
} else {
|
||||
fmt.Printf("[H3C DEBUG] First 200 chars: %q\n", outputs[2][:200])
|
||||
fmt.Printf("[H3C DEBUG] First 3000 chars:\n%s\n", outputs[2][:3000])
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[H3C DEBUG] display interface output is EMPTY!\n")
|
||||
}
|
||||
|
||||
// outputs[3] 是 display interface brief
|
||||
fmt.Printf("[H3C BRIEF DEBUG] display interface brief output length: %d\n", len(outputs[3]))
|
||||
if len(outputs[3]) > 0 {
|
||||
if len(outputs[3]) <= 3000 {
|
||||
fmt.Printf("[H3C BRIEF DEBUG] Complete brief output:\n%s\n", outputs[3])
|
||||
} else {
|
||||
fmt.Printf("[H3C BRIEF DEBUG] First 3000 chars:\n%s\n", outputs[3][:3000])
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[H3C BRIEF DEBUG] display interface brief output is EMPTY!\n")
|
||||
}
|
||||
|
||||
if outputs[2] == "" {
|
||||
fmt.Printf("Warning: 'display interface' output is empty for device %s\n", device.IP)
|
||||
} else {
|
||||
@@ -111,9 +122,9 @@ func (p *H3CParser) parseVersion(device *models.Device, output string) {
|
||||
}
|
||||
}
|
||||
|
||||
uptimeRegex := regexp.MustCompile(`uptime is\s+(\d+\s+\S+)`)
|
||||
uptimeRegex := regexp.MustCompile(`uptime is\s+(.+)`)
|
||||
if matches := uptimeRegex.FindStringSubmatch(output); len(matches) > 1 {
|
||||
device.Uptime = matches[1]
|
||||
device.Uptime = strings.TrimSpace(matches[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +153,15 @@ func (p *H3CParser) parseInterfaces(interfaceOutput, briefOutput string) []model
|
||||
|
||||
if brief, ok := briefMap[currentInterface.Name]; ok {
|
||||
currentInterface.IP = brief.IP
|
||||
if currentInterface.Speed == "" {
|
||||
currentInterface.Speed = brief.Speed
|
||||
}
|
||||
if currentInterface.Duplex == "" {
|
||||
currentInterface.Duplex = brief.Duplex
|
||||
}
|
||||
if currentInterface.VLAN == "" {
|
||||
currentInterface.VLAN = brief.VLAN
|
||||
}
|
||||
}
|
||||
pendingInterfaceName = ""
|
||||
continue
|
||||
@@ -162,6 +182,15 @@ func (p *H3CParser) parseInterfaces(interfaceOutput, briefOutput string) []model
|
||||
|
||||
if brief, ok := briefMap[currentInterface.Name]; ok {
|
||||
currentInterface.IP = brief.IP
|
||||
if currentInterface.Speed == "" {
|
||||
currentInterface.Speed = brief.Speed
|
||||
}
|
||||
if currentInterface.Duplex == "" {
|
||||
currentInterface.Duplex = brief.Duplex
|
||||
}
|
||||
if currentInterface.VLAN == "" {
|
||||
currentInterface.VLAN = brief.VLAN
|
||||
}
|
||||
}
|
||||
pendingInterfaceName = ""
|
||||
continue
|
||||
@@ -192,9 +221,48 @@ func (p *H3CParser) parseInterfaces(interfaceOutput, briefOutput string) []model
|
||||
currentInterface.Mask = cidrToMask(matches[2])
|
||||
}
|
||||
|
||||
if speedRegex := regexp.MustCompile(`(\d+)\s+(Kbps|Mbps|Gbps)`); speedRegex.MatchString(line) {
|
||||
// 匹配速度格式1: Speed : 1000Mbps 或 Speed : 10Gbps
|
||||
if speedRegex := regexp.MustCompile(`Speed\s*:\s*(\d+)\s*(Kbps|Mbps|Gbps)`); speedRegex.MatchString(line) {
|
||||
matches := speedRegex.FindStringSubmatch(line)
|
||||
currentInterface.Speed = matches[1] + " " + matches[2]
|
||||
currentInterface.Speed = matches[1] + matches[2]
|
||||
}
|
||||
// 匹配速度格式2: Speed: 1000, Loopback: not set (纯数字Mbps)
|
||||
if speedRegex2 := regexp.MustCompile(`Speed\s*:\s*(\d+)(?:,|$)`); speedRegex2.MatchString(line) && currentInterface.Speed == "" {
|
||||
matches := speedRegex2.FindStringSubmatch(line)
|
||||
currentInterface.Speed = matches[1] + "Mbps"
|
||||
}
|
||||
|
||||
// 匹配双工: Duplex: Full 或 Duplex: Half
|
||||
if duplexRegex := regexp.MustCompile(`Duplex\s*:\s*(\S+)`); duplexRegex.MatchString(line) && currentInterface.Duplex == "" {
|
||||
currentInterface.Duplex = duplexRegex.FindStringSubmatch(line)[1]
|
||||
}
|
||||
|
||||
// 匹配VLAN信息: Port link-type: Access / Trunk / Hybrid
|
||||
if linkTypeRegex := regexp.MustCompile(`Port link-type\s*:\s*(\S+)`); linkTypeRegex.MatchString(line) {
|
||||
linkType := linkTypeRegex.FindStringSubmatch(line)[1]
|
||||
currentInterface.VLAN = linkType
|
||||
}
|
||||
// 匹配Untagged VLAN: Untagged VLAN ID: 10
|
||||
if untaggedRegex := regexp.MustCompile(`Untagged VLAN ID\s*:\s*(\S+)`); untaggedRegex.MatchString(line) {
|
||||
untagged := untaggedRegex.FindStringSubmatch(line)[1]
|
||||
if untagged != "none" && untagged != "--" {
|
||||
if currentInterface.VLAN != "" {
|
||||
currentInterface.VLAN = currentInterface.VLAN + " " + untagged
|
||||
} else {
|
||||
currentInterface.VLAN = untagged
|
||||
}
|
||||
}
|
||||
}
|
||||
// 匹配Tagged VLAN: Tagged VLAN ID: 100-200
|
||||
if taggedRegex := regexp.MustCompile(`Tagged VLAN ID\s*:\s*(\S+)`); taggedRegex.MatchString(line) {
|
||||
tagged := taggedRegex.FindStringSubmatch(line)[1]
|
||||
if tagged != "none" && tagged != "--" {
|
||||
if currentInterface.VLAN != "" {
|
||||
currentInterface.VLAN = currentInterface.VLAN + " (T:" + tagged + ")"
|
||||
} else {
|
||||
currentInterface.VLAN = "T:" + tagged
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,19 +344,133 @@ func normalizeMACFormat(mac string) string {
|
||||
return mac
|
||||
}
|
||||
|
||||
// expandH3CInterfaceName 将H3C brief中的缩写接口名展开为全名
|
||||
func expandH3CInterfaceName(shortName string) string {
|
||||
// GE1/0/10 -> GigabitEthernet1/0/10
|
||||
if strings.HasPrefix(shortName, "GE") {
|
||||
return "GigabitEthernet" + shortName[2:]
|
||||
}
|
||||
// TGE1/0/52 -> Ten-GigabitEthernet1/0/52
|
||||
if strings.HasPrefix(shortName, "TGE") {
|
||||
return "Ten-GigabitEthernet" + shortName[3:]
|
||||
}
|
||||
// FGE1/0/53 -> FortyGigE1/0/53
|
||||
if strings.HasPrefix(shortName, "FGE") {
|
||||
return "FortyGigE" + shortName[3:]
|
||||
}
|
||||
// HGE1/0/1 -> HundredGigE1/0/1
|
||||
if strings.HasPrefix(shortName, "HGE") {
|
||||
return "HundredGigE" + shortName[3:]
|
||||
}
|
||||
// XGE1/0/1 -> Ten-GigabitEthernet1/0/1 (有些型号用XGE)
|
||||
if strings.HasPrefix(shortName, "XGE") {
|
||||
return "Ten-GigabitEthernet" + shortName[3:]
|
||||
}
|
||||
// Vlan8 -> Vlan-interface8
|
||||
if strings.HasPrefix(shortName, "Vlan") {
|
||||
return "Vlan-interface" + shortName[4:]
|
||||
}
|
||||
return shortName
|
||||
}
|
||||
|
||||
func (p *H3CParser) parseInterfaceBrief(output string) map[string]models.Interface {
|
||||
interfaces := make(map[string]models.Interface)
|
||||
lines := strings.Split(output, "\n")
|
||||
|
||||
// 状态机: 0=未开始, 1=route mode数据, 2=bridge mode数据
|
||||
mode := 0
|
||||
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 4 {
|
||||
iface := models.Interface{
|
||||
Name: fields[0],
|
||||
IP: fields[1],
|
||||
Status: strings.ToLower(fields[3]),
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "---") {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检测 route mode section
|
||||
if strings.Contains(trimmed, "route mode") {
|
||||
mode = 1
|
||||
continue
|
||||
}
|
||||
// 检测 bridge mode section
|
||||
if strings.Contains(trimmed, "bridge mode") {
|
||||
mode = 2
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过表头行
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) > 0 && (fields[0] == "Interface" || fields[0] == "interface") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Route mode: Interface Link Protocol Primary IP Description
|
||||
if mode == 1 && len(fields) >= 3 {
|
||||
shortName := fields[0]
|
||||
fullName := expandH3CInterfaceName(shortName)
|
||||
ip := ""
|
||||
if len(fields) >= 4 && fields[3] != "--" {
|
||||
ip = fields[3]
|
||||
}
|
||||
interfaces[iface.Name] = iface
|
||||
iface := models.Interface{
|
||||
Name: fullName,
|
||||
Status: strings.ToLower(fields[1]),
|
||||
IP: ip,
|
||||
}
|
||||
interfaces[fullName] = iface
|
||||
}
|
||||
|
||||
// Bridge mode: Interface Link Speed Duplex Type PVID Description
|
||||
if mode == 2 && len(fields) >= 5 {
|
||||
shortName := fields[0]
|
||||
fullName := expandH3CInterfaceName(shortName)
|
||||
|
||||
speed := fields[2]
|
||||
if speed == "auto" {
|
||||
speed = "auto"
|
||||
}
|
||||
|
||||
duplex := fields[3]
|
||||
switch duplex {
|
||||
case "A":
|
||||
duplex = "auto"
|
||||
case "F(a)", "F":
|
||||
duplex = "full"
|
||||
case "H":
|
||||
duplex = "half"
|
||||
}
|
||||
|
||||
// VLAN: Type(A/T/H) + PVID
|
||||
vlan := ""
|
||||
if len(fields) >= 5 {
|
||||
linkType := fields[4]
|
||||
switch linkType {
|
||||
case "A":
|
||||
vlan = "Access"
|
||||
case "T":
|
||||
vlan = "Trunk"
|
||||
case "H":
|
||||
vlan = "Hybrid"
|
||||
}
|
||||
}
|
||||
if len(fields) >= 6 {
|
||||
vlan = vlan + " " + fields[5]
|
||||
}
|
||||
|
||||
iface := models.Interface{
|
||||
Name: fullName,
|
||||
Status: strings.ToLower(fields[1]),
|
||||
Speed: speed,
|
||||
Duplex: duplex,
|
||||
VLAN: strings.TrimSpace(vlan),
|
||||
}
|
||||
|
||||
// 如果 route mode 已有此接口(通常不会有), 保留 IP 信息
|
||||
if existing, ok := interfaces[fullName]; ok {
|
||||
if existing.IP != "" {
|
||||
iface.IP = existing.IP
|
||||
}
|
||||
}
|
||||
interfaces[fullName] = iface
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+119
-10
@@ -18,7 +18,7 @@ func (p *HuaweiParser) GetCommands() []string {
|
||||
return []string{
|
||||
"display version",
|
||||
"display interface",
|
||||
"display ip interface brief",
|
||||
"display interface brief",
|
||||
"display lldp neighbor",
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,15 @@ func (p *HuaweiParser) parseInterfaces(interfaceOutput, briefOutput string) []mo
|
||||
|
||||
if brief, ok := briefMap[currentInterface.Name]; ok {
|
||||
currentInterface.IP = brief.IP
|
||||
if currentInterface.Speed == "" {
|
||||
currentInterface.Speed = brief.Speed
|
||||
}
|
||||
if currentInterface.Duplex == "" {
|
||||
currentInterface.Duplex = brief.Duplex
|
||||
}
|
||||
if currentInterface.VLAN == "" {
|
||||
currentInterface.VLAN = brief.VLAN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,11 +114,41 @@ func (p *HuaweiParser) parseInterfaces(interfaceOutput, briefOutput string) []mo
|
||||
currentInterface.Mask = matches[2]
|
||||
}
|
||||
|
||||
// 带宽
|
||||
if speedRegex := regexp.MustCompile(`(\d+)\s+(Kbps|Mbps|Gbps)`); speedRegex.MatchString(line) {
|
||||
// 带宽格式1: Speed : 1000Mbps
|
||||
if speedRegex := regexp.MustCompile(`Speed\s*:\s*(\d+)\s*(Kbps|Mbps|Gbps)`); speedRegex.MatchString(line) {
|
||||
matches := speedRegex.FindStringSubmatch(line)
|
||||
currentInterface.Speed = matches[1] + matches[2]
|
||||
}
|
||||
// 带宽格式2: BW 1000000 Kbit
|
||||
if speedRegex2 := regexp.MustCompile(`BW\s+(\d+)\s+(Kbit|Mbit|Gbit)`); speedRegex2.MatchString(line) && currentInterface.Speed == "" {
|
||||
matches := speedRegex2.FindStringSubmatch(line)
|
||||
currentInterface.Speed = matches[1] + " " + matches[2]
|
||||
}
|
||||
|
||||
// VLAN信息: Port link-type
|
||||
if linkTypeRegex := regexp.MustCompile(`Port link-type\s*:\s*(\S+)`); linkTypeRegex.MatchString(line) {
|
||||
currentInterface.VLAN = linkTypeRegex.FindStringSubmatch(line)[1]
|
||||
}
|
||||
if untaggedRegex := regexp.MustCompile(`Untagged VLAN ID\s*:\s*(\S+)`); untaggedRegex.MatchString(line) {
|
||||
untagged := untaggedRegex.FindStringSubmatch(line)[1]
|
||||
if untagged != "none" && untagged != "--" {
|
||||
if currentInterface.VLAN != "" {
|
||||
currentInterface.VLAN = currentInterface.VLAN + " " + untagged
|
||||
} else {
|
||||
currentInterface.VLAN = untagged
|
||||
}
|
||||
}
|
||||
}
|
||||
if taggedRegex := regexp.MustCompile(`Tagged VLAN ID\s*:\s*(\S+)`); taggedRegex.MatchString(line) {
|
||||
tagged := taggedRegex.FindStringSubmatch(line)[1]
|
||||
if tagged != "none" && tagged != "--" {
|
||||
if currentInterface.VLAN != "" {
|
||||
currentInterface.VLAN = currentInterface.VLAN + " (T:" + tagged + ")"
|
||||
} else {
|
||||
currentInterface.VLAN = "T:" + tagged
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,15 +163,85 @@ func (p *HuaweiParser) parseInterfaceBrief(output string) map[string]models.Inte
|
||||
interfaces := make(map[string]models.Interface)
|
||||
lines := strings.Split(output, "\n")
|
||||
|
||||
mode := 0 // 0=未开始, 1=route mode, 2=bridge mode
|
||||
for _, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 4 {
|
||||
iface := models.Interface{
|
||||
Name: fields[0],
|
||||
IP: fields[1],
|
||||
Status: strings.ToLower(fields[3]),
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "---") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(trimmed, "route mode") {
|
||||
mode = 1
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, "bridge mode") {
|
||||
mode = 2
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) > 0 && (fields[0] == "Interface" || fields[0] == "interface") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Route mode: Interface Link Protocol Primary IP Description
|
||||
if mode == 1 && len(fields) >= 3 {
|
||||
fullName := expandH3CInterfaceName(fields[0])
|
||||
ip := ""
|
||||
if len(fields) >= 4 && fields[3] != "--" {
|
||||
ip = fields[3]
|
||||
}
|
||||
interfaces[iface.Name] = iface
|
||||
iface := models.Interface{
|
||||
Name: fullName,
|
||||
Status: strings.ToLower(fields[1]),
|
||||
IP: ip,
|
||||
}
|
||||
interfaces[fullName] = iface
|
||||
}
|
||||
|
||||
// Bridge mode: Interface Link Speed Duplex Type PVID Description
|
||||
if mode == 2 && len(fields) >= 5 {
|
||||
fullName := expandH3CInterfaceName(fields[0])
|
||||
|
||||
speed := fields[2]
|
||||
duplex := fields[3]
|
||||
switch duplex {
|
||||
case "A":
|
||||
duplex = "auto"
|
||||
case "F(a)", "F":
|
||||
duplex = "full"
|
||||
case "H":
|
||||
duplex = "half"
|
||||
}
|
||||
|
||||
vlan := ""
|
||||
if len(fields) >= 5 {
|
||||
switch fields[4] {
|
||||
case "A":
|
||||
vlan = "Access"
|
||||
case "T":
|
||||
vlan = "Trunk"
|
||||
case "H":
|
||||
vlan = "Hybrid"
|
||||
}
|
||||
}
|
||||
if len(fields) >= 6 {
|
||||
vlan = vlan + " " + fields[5]
|
||||
}
|
||||
|
||||
iface := models.Interface{
|
||||
Name: fullName,
|
||||
Status: strings.ToLower(fields[1]),
|
||||
Speed: speed,
|
||||
Duplex: duplex,
|
||||
VLAN: strings.TrimSpace(vlan),
|
||||
}
|
||||
if existing, ok := interfaces[fullName]; ok {
|
||||
if existing.IP != "" {
|
||||
iface.IP = existing.IP
|
||||
}
|
||||
}
|
||||
interfaces[fullName] = iface
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -26,6 +32,16 @@ func (b *Builder) AddDevices(devices []models.Device) {
|
||||
b.devices = append(b.devices, devices...)
|
||||
}
|
||||
|
||||
// RemoveDevice 根据ID或IP移除设备
|
||||
func (b *Builder) RemoveDevice(id string) {
|
||||
for i, d := range b.devices {
|
||||
if d.ID == id || d.IP == id {
|
||||
b.devices = append(b.devices[:i], b.devices[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build 构建拓扑图
|
||||
func (b *Builder) Build() models.TopologyGraph {
|
||||
graph := models.TopologyGraph{
|
||||
@@ -141,40 +157,90 @@ func (b *Builder) Build() models.TopologyGraph {
|
||||
}
|
||||
}
|
||||
|
||||
// 策略3b: 通过MAC前缀匹配(新增)
|
||||
// 当精确MAC匹配失败时,尝试通过MAC前缀匹配(适用于同一设备的多个接口)
|
||||
// 策略3b: MAC前缀匹配(使用12字符前缀 - 更精确)
|
||||
// 同品牌设备虽然4字符前缀相同,但12字符前缀通常不同
|
||||
if targetIP == "" && neighbor.RemoteMAC != "" {
|
||||
neighborMACPrefix := getMACPrefix(neighbor.RemoteMAC)
|
||||
fmt.Printf(" Trying MAC prefix match: %s (prefix: %s)\n", neighbor.RemoteMAC, neighborMACPrefix)
|
||||
// 使用12字符前缀(例如:642f-c7e0-03)
|
||||
macPrefix := ""
|
||||
if len(neighbor.RemoteMAC) >= 12 {
|
||||
macPrefix = neighbor.RemoteMAC[:12]
|
||||
} else {
|
||||
macPrefix = neighbor.RemoteMAC
|
||||
}
|
||||
|
||||
fmt.Printf(" Trying MAC prefix match (12 chars): %s (prefix: %s)\n", neighbor.RemoteMAC, macPrefix)
|
||||
|
||||
var matchedDevices []string
|
||||
|
||||
for _, d := range b.devices {
|
||||
if d.IP == device.IP {
|
||||
continue // 跳过自己
|
||||
}
|
||||
|
||||
// 检查该设备的MAC地址是否有相同前缀
|
||||
// 检查设备d的MAC地址是否有匹配的前缀
|
||||
matchingMACs := 0
|
||||
for _, mac := range d.MACAddresses {
|
||||
if getMACPrefix(mac) == neighborMACPrefix {
|
||||
if len(mac) >= 12 && mac[:12] == macPrefix {
|
||||
matchingMACs++
|
||||
}
|
||||
}
|
||||
|
||||
// 如果该设备有多个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)
|
||||
if matchingMACs >= 3 {
|
||||
matchedDevices = append(matchedDevices, d.IP)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有唯一匹配时才建立连接
|
||||
if len(matchedDevices) == 1 {
|
||||
targetIP = matchedDevices[0]
|
||||
matchMethod = fmt.Sprintf("MAC-prefix-12(%s)", macPrefix)
|
||||
fmt.Printf(" ✓ Matched by MAC prefix-12 (unique): %s -> %s\n", neighbor.RemoteMAC, targetIP)
|
||||
} else if len(matchedDevices) > 1 {
|
||||
fmt.Printf(" ✗ Ambiguous MAC prefix-12 match: %s matched %v\n", macPrefix, matchedDevices)
|
||||
} else {
|
||||
fmt.Printf(" ✗ No device matches MAC prefix-12 %s\n", macPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// 策略3c: LLDP接口匹配(改进 - 单向但要求唯一性)
|
||||
// 如果设备A的LLDP信息显示它连接到接口Y
|
||||
// 而设备B有接口Y,则A连接到B
|
||||
// 但如果有多个设备都有接口Y,则不匹配(避免歧义)
|
||||
if targetIP == "" && neighbor.RemoteMAC != "" && neighbor.RemoteInterface != "" {
|
||||
fmt.Printf(" Trying LLDP interface match: looking for device with interface %s\n", neighbor.RemoteInterface)
|
||||
|
||||
var matchedDevices []string
|
||||
|
||||
for _, d := range b.devices {
|
||||
if d.IP == device.IP {
|
||||
continue // 跳过自己
|
||||
}
|
||||
|
||||
// 检查设备d是否有这个接口
|
||||
for _, iface := range d.Interfaces {
|
||||
if iface.Name == neighbor.RemoteInterface {
|
||||
matchedDevices = append(matchedDevices, d.IP)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只有唯一匹配时才建立连接
|
||||
if len(matchedDevices) == 1 {
|
||||
targetIP = matchedDevices[0]
|
||||
matchMethod = fmt.Sprintf("LLDP-interface(%s->%s)", neighbor.LocalInterface, neighbor.RemoteInterface)
|
||||
fmt.Printf(" ✓ Matched by LLDP interface (unique): %s %s -> %s %s\n",
|
||||
device.IP, neighbor.LocalInterface, targetIP, neighbor.RemoteInterface)
|
||||
} else if len(matchedDevices) > 1 {
|
||||
fmt.Printf(" ✗ Ambiguous: multiple devices have interface %s: %v\n",
|
||||
neighbor.RemoteInterface, matchedDevices)
|
||||
} else {
|
||||
fmt.Printf(" ✗ No device found with interface %s\n", neighbor.RemoteInterface)
|
||||
}
|
||||
}
|
||||
|
||||
// 策略4: 通过本地接口IP网段匹配(新增)
|
||||
// 注意:此策略容易产生误匹配,仅在必要时使用
|
||||
if targetIP == "" {
|
||||
// 查找本地接口的IP地址
|
||||
localInterfaceIP := ""
|
||||
@@ -186,7 +252,7 @@ func (b *Builder) Build() models.TopologyGraph {
|
||||
}
|
||||
|
||||
if localInterfaceIP != "" {
|
||||
fmt.Printf(" Trying subnet match: local interface %s has IP %s\n",
|
||||
fmt.Printf(" ⚠ Trying subnet match (risky): local interface %s has IP %s\n",
|
||||
neighbor.LocalInterface, localInterfaceIP)
|
||||
|
||||
// 计算本地接口的网段
|
||||
@@ -202,7 +268,7 @@ func (b *Builder) Build() models.TopologyGraph {
|
||||
if getSubnet(d.IP) == localSubnet {
|
||||
targetIP = d.IP
|
||||
matchMethod = "subnet(management IP)"
|
||||
fmt.Printf(" ✓ Matched by subnet: %s in %s\n", d.IP, localSubnet)
|
||||
fmt.Printf(" ⚠ Matched by subnet (risky): %s in %s\n", d.IP, localSubnet)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -211,7 +277,7 @@ func (b *Builder) Build() models.TopologyGraph {
|
||||
if iface.IP != "" && getSubnet(iface.IP) == localSubnet {
|
||||
targetIP = d.IP
|
||||
matchMethod = fmt.Sprintf("subnet(interface %s)", iface.Name)
|
||||
fmt.Printf(" ✓ Matched by subnet: %s (%s) in %s\n",
|
||||
fmt.Printf(" ⚠ Matched by subnet (risky): %s (%s) in %s\n",
|
||||
d.IP, iface.Name, localSubnet)
|
||||
break
|
||||
}
|
||||
@@ -221,27 +287,9 @@ func (b *Builder) Build() models.TopologyGraph {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 策略4b: 本地接口没有IP,尝试使用设备管理IP进行子网匹配(新增)
|
||||
// 注意:只有当该网段只有2台设备时才使用此策略(点对点连接)
|
||||
fmt.Printf(" Trying subnet match with management IP: %s\n", device.IP)
|
||||
localSubnet := getSubnet(device.IP)
|
||||
|
||||
// 统计在该网段的设备数量
|
||||
var devicesInSubnet []string
|
||||
for _, d := range b.devices {
|
||||
if d.IP != device.IP && getSubnet(d.IP) == localSubnet {
|
||||
devicesInSubnet = append(devicesInSubnet, d.IP)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当网段中恰好有1台其他设备时才匹配(点对点)
|
||||
if len(devicesInSubnet) == 1 {
|
||||
targetIP = devicesInSubnet[0]
|
||||
matchMethod = "subnet(management IP both sides)"
|
||||
fmt.Printf(" ✓ Matched by management subnet: %s in %s (only device in subnet)\n", targetIP, localSubnet)
|
||||
} else if len(devicesInSubnet) > 1 {
|
||||
fmt.Printf(" ✗ Skipping subnet match: %d devices in subnet %s (ambiguous)\n", len(devicesInSubnet)+1, localSubnet)
|
||||
}
|
||||
// 策略4b: 本地接口没有IP,尝试使用设备管理IP进行子网匹配(高风险策略 - 已禁用)
|
||||
// 此策略容易产生错误连接,暂时禁用
|
||||
fmt.Printf(" ✗ Skipping subnet match with management IP (disabled - too risky)\n")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-9
@@ -41,6 +41,7 @@ type Interface struct {
|
||||
Speed string `json:"speed"`
|
||||
Duplex string `json:"duplex"`
|
||||
MTU int `json:"mtu"`
|
||||
VLAN string `json:"vlan"` // VLAN信息(PVID/Access/Trunk)
|
||||
InBytes int64 `json:"in_bytes"`
|
||||
OutBytes int64 `json:"out_bytes"`
|
||||
InPackets int64 `json:"in_packets"`
|
||||
@@ -59,15 +60,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 +95,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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+175
-1
@@ -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 {
|
||||
@@ -64,6 +82,16 @@ header h1 {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #607D8B;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -72,6 +100,8 @@ header h1 {
|
||||
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
min-width: 200px;
|
||||
max-width: 600px;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
@@ -162,6 +192,7 @@ header h1 {
|
||||
.content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
#cy {
|
||||
@@ -171,18 +202,48 @@ header h1 {
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
width: 350px;
|
||||
width: 600px;
|
||||
min-width: 300px;
|
||||
max-width: 1000px;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
box-shadow: -2px 0 5px rgba(0,0,0,0.1);
|
||||
display: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 拖拽手柄样式 */
|
||||
.resizer {
|
||||
width: 5px;
|
||||
cursor: col-resize;
|
||||
background: #e0e0e0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.resizer:hover,
|
||||
.resizer.resizing {
|
||||
background: #667eea;
|
||||
}
|
||||
|
||||
.resizer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 3px;
|
||||
height: 30px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.detail-panel h3 {
|
||||
margin-bottom: 15px;
|
||||
color: #667eea;
|
||||
@@ -269,3 +330,116 @@ header h1 {
|
||||
color: #FF9800;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* SSH终端样式 */
|
||||
.terminal-modal-content {
|
||||
width: 95vw;
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.terminal-modal-content .modal-header-drag {
|
||||
cursor: move;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
margin-bottom: 5px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
width: 100%;
|
||||
height: calc(85vh - 80px);
|
||||
background: #1e1e1e;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
+114
-4
@@ -5,22 +5,31 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>网络拓扑发现系统</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<script src="https://unpkg.com/cytoscape@3.26.0/dist/cytoscape.min.js"></script>
|
||||
<script src="/vendor/cytoscape.min.js"></script>
|
||||
<link rel="stylesheet" href="/vendor/xterm.css">
|
||||
<script src="/vendor/xterm.js"></script>
|
||||
<script src="/vendor/xterm-addon-fit.js"></script>
|
||||
<script src="/vendor/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>
|
||||
<button id="btn-logout" class="btn btn-danger" style="margin-left:auto">登出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-content">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="panel">
|
||||
<h3>扫描配置</h3>
|
||||
<form id="scan-form">
|
||||
@@ -60,11 +69,17 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 左侧拖拽手柄 -->
|
||||
<div class="resizer" id="resizer-left"></div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="content">
|
||||
<div id="cy"></div>
|
||||
</main>
|
||||
|
||||
<!-- 右侧拖拽手柄 -->
|
||||
<div class="resizer" id="resizer-right"></div>
|
||||
|
||||
<!-- 详情面板 -->
|
||||
<aside class="detail-panel" id="detail-panel">
|
||||
<h3>设备详情</h3>
|
||||
@@ -73,6 +88,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSH终端模态框 -->
|
||||
<div id="modal-terminal" class="modal">
|
||||
<div class="modal-content terminal-modal-content">
|
||||
<div class="modal-header-drag">
|
||||
<span class="close-terminal">×</span>
|
||||
<h2 style="display:inline;margin:0">SSH 终端 - <span id="terminal-device-name"></span></h2>
|
||||
</div>
|
||||
<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">×</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">
|
||||
@@ -86,9 +140,9 @@
|
||||
<div class="form-group">
|
||||
<label for="device-type">设备类型:</label>
|
||||
<select id="device-type" required>
|
||||
<option value="cisco">Cisco</option>
|
||||
<option value="huawei">华为</option>
|
||||
<option value="h3c">H3C</option>
|
||||
<option value="huawei">华为</option>
|
||||
<option value="cisco">Cisco</option>
|
||||
<option value="asa">ASA防火墙</option>
|
||||
<option value="linux">Linux服务器</option>
|
||||
<option value="windows">Windows Server</option>
|
||||
@@ -107,6 +161,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建拓扑模态框 -->
|
||||
<div id="modal-new-topology" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-new-topology">×</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-edit-device" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-edit-device">×</span>
|
||||
<h2>编辑设备</h2>
|
||||
<form id="edit-device-form" onsubmit="saveEditDevice(event)">
|
||||
<input type="hidden" id="edit-device-id">
|
||||
<div class="form-group">
|
||||
<label for="edit-hostname">主机名:</label>
|
||||
<input type="text" id="edit-hostname" required placeholder="例: BJ-SW01">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-type">设备类型:</label>
|
||||
<select id="edit-type" required>
|
||||
<option value="h3c">H3C</option>
|
||||
<option value="huawei">Huawei</option>
|
||||
<option value="cisco">Cisco</option>
|
||||
<option value="asa">ASA</option>
|
||||
<option value="linux">Linux</option>
|
||||
<option value="windows">Windows</option>
|
||||
</select>
|
||||
</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">×</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>
|
||||
|
||||
+600
-4
@@ -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,27 @@ 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.querySelector('.close-edit-device').addEventListener('click', function() {
|
||||
document.getElementById('modal-edit-device').classList.remove('active');
|
||||
});
|
||||
|
||||
// 新建拓扑表单
|
||||
document.getElementById('new-topology-form').addEventListener('submit', createTopology);
|
||||
|
||||
// 扫描按钮
|
||||
document.getElementById('btn-scan').addEventListener('click', startScan);
|
||||
|
||||
@@ -102,6 +124,57 @@ function initEventListeners() {
|
||||
|
||||
// 导出按钮
|
||||
document.getElementById('btn-export').addEventListener('click', exportTopology);
|
||||
|
||||
// 登出按钮
|
||||
var logoutBtn = document.getElementById('btn-logout');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', function() {
|
||||
window.location.href = '/api/logout';
|
||||
});
|
||||
}
|
||||
|
||||
// SSH终端相关
|
||||
document.querySelector('.close-terminal').addEventListener('click', closeTerminal);
|
||||
|
||||
// 终端模态框拖动功能
|
||||
(function() {
|
||||
var dragHandle = document.querySelector('.modal-header-drag');
|
||||
var modal = document.querySelector('.terminal-modal-content');
|
||||
var isDragging = false;
|
||||
var offsetX = 0, offsetY = 0;
|
||||
|
||||
if (dragHandle && modal) {
|
||||
dragHandle.addEventListener('mousedown', function(e) {
|
||||
if (e.target.classList.contains('close-terminal')) return;
|
||||
isDragging = true;
|
||||
var rect = modal.getBoundingClientRect();
|
||||
offsetX = e.clientX - rect.left;
|
||||
offsetY = e.clientY - rect.top;
|
||||
modal.style.margin = '0';
|
||||
modal.style.position = 'absolute';
|
||||
modal.style.left = rect.left + 'px';
|
||||
modal.style.top = rect.top + 'px';
|
||||
e.preventDefault();
|
||||
});
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!isDragging) return;
|
||||
modal.style.left = (e.clientX - offsetX) + 'px';
|
||||
modal.style.top = (e.clientY - offsetY) + 'px';
|
||||
});
|
||||
document.addEventListener('mouseup', function() {
|
||||
isDragging = false;
|
||||
});
|
||||
}
|
||||
})();
|
||||
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 +218,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 +251,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -291,6 +382,14 @@ async function loadDeviceList() {
|
||||
async function showDeviceDetail(deviceId) {
|
||||
try {
|
||||
const response = await fetch(`/api/device/${deviceId}`);
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.error('获取设备详情失败:', response.status, response.statusText);
|
||||
return;
|
||||
}
|
||||
const device = await response.json();
|
||||
|
||||
const detailPanel = document.getElementById('detail-panel');
|
||||
@@ -304,6 +403,11 @@ 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>
|
||||
<div style="display:flex;gap:10px;margin-top:10px">
|
||||
<button class="btn btn-primary" style="flex:1" onclick="openSSHTerminal('${device.ip}', '${device.hostname || device.ip}')">SSH 连接</button>
|
||||
<button class="btn btn-secondary" style="flex:1" onclick="openEditDevice('${device.id}', '${device.hostname}', '${device.type}')">编辑</button>
|
||||
<button class="btn btn-danger" style="flex:1" onclick="deleteDevice('${device.id}')">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<h4>接口信息 (${device.interfaces.length})</h4>
|
||||
@@ -315,6 +419,8 @@ async function showDeviceDetail(deviceId) {
|
||||
<p>IP: ${iface.ip || 'N/A'}</p>
|
||||
<p>MAC: ${iface.mac || 'N/A'}</p>
|
||||
<p>速度: ${iface.speed || 'N/A'}</p>
|
||||
<p>双工: ${iface.duplex || 'N/A'}</p>
|
||||
<p>VLAN: ${iface.vlan || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
@@ -390,3 +496,493 @@ 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 = '';
|
||||
// 重置终端模态框位置
|
||||
var modal = document.querySelector('.terminal-modal-content');
|
||||
if (modal) {
|
||||
modal.style.position = '';
|
||||
modal.style.left = '';
|
||||
modal.style.top = '';
|
||||
modal.style.margin = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 删除设备
|
||||
async function deleteDevice(deviceId) {
|
||||
if (!confirm('确定要删除设备 ' + deviceId + ' 吗?')) return;
|
||||
try {
|
||||
const response = await fetch(`/api/device/${deviceId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) {
|
||||
alert('删除成功');
|
||||
document.getElementById('detail-panel').classList.remove('active');
|
||||
// 重新加载拓扑和设备列表
|
||||
loadTopology();
|
||||
loadDeviceList();
|
||||
} else {
|
||||
alert('删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('删除失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑设备模态框
|
||||
function openEditDevice(id, hostname, type) {
|
||||
document.getElementById('edit-device-id').value = id;
|
||||
document.getElementById('edit-hostname').value = hostname;
|
||||
document.getElementById('edit-type').value = type;
|
||||
document.getElementById('modal-edit-device').classList.add('active');
|
||||
}
|
||||
|
||||
// 保存编辑设备
|
||||
async function saveEditDevice(event) {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById('edit-device-id').value;
|
||||
const hostname = document.getElementById('edit-hostname').value;
|
||||
const type = document.getElementById('edit-type').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/device/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hostname, type })
|
||||
});
|
||||
if (response.ok) {
|
||||
alert('修改成功');
|
||||
document.getElementById('modal-edit-device').classList.remove('active');
|
||||
document.getElementById('detail-panel').classList.remove('active');
|
||||
loadTopology();
|
||||
loadDeviceList();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert('修改失败: ' + (data.error || '未知错误'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('修改失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 面板拖拽调整大小功能
|
||||
(function() {
|
||||
// 左侧面板拖拽
|
||||
var resizerLeft = document.getElementById('resizer-left');
|
||||
var sidebar = document.getElementById('sidebar');
|
||||
|
||||
if (resizerLeft && sidebar) {
|
||||
var isResizingLeft = false;
|
||||
|
||||
resizerLeft.addEventListener('mousedown', function(e) {
|
||||
isResizingLeft = true;
|
||||
resizerLeft.classList.add('resizing');
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!isResizingLeft) return;
|
||||
|
||||
var newWidth = e.clientX;
|
||||
var minWidth = 200;
|
||||
var maxWidth = 600;
|
||||
|
||||
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||
sidebar.style.width = newWidth + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function() {
|
||||
if (isResizingLeft) {
|
||||
isResizingLeft = false;
|
||||
resizerLeft.classList.remove('resizing');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 右侧面板拖拽
|
||||
var resizerRight = document.getElementById('resizer-right');
|
||||
var detailPanel = document.getElementById('detail-panel');
|
||||
|
||||
if (resizerRight && detailPanel) {
|
||||
var isResizingRight = false;
|
||||
|
||||
resizerRight.addEventListener('mousedown', function(e) {
|
||||
isResizingRight = true;
|
||||
resizerRight.classList.add('resizing');
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!isResizingRight) return;
|
||||
|
||||
var containerWidth = document.querySelector('.main-content').offsetWidth;
|
||||
var newWidth = containerWidth - e.clientX;
|
||||
var minWidth = 300;
|
||||
var maxWidth = 1000;
|
||||
|
||||
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||
detailPanel.style.width = newWidth + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function() {
|
||||
if (isResizingRight) {
|
||||
isResizingRight = false;
|
||||
resizerRight.classList.remove('resizing');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - 网络拓扑发现系统</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
padding: 50px 40px;
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
.login-card h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
.login-card p {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
margin-bottom: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
transition: border-color 0.3s;
|
||||
outline: none;
|
||||
}
|
||||
.form-group input:focus {
|
||||
border-color: #667eea;
|
||||
}
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.login-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.login-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error-msg {
|
||||
color: #f44336;
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<h1>🌐 网络拓扑发现系统</h1>
|
||||
<p>请登录以继续</p>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" name="username" required autofocus placeholder="请输入用户名">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" name="password" required placeholder="请输入密码">
|
||||
</div>
|
||||
<button type="submit" class="login-btn" id="login-btn">登 录</button>
|
||||
<div class="error-msg" id="error-msg"></div>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('login-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById('login-btn');
|
||||
var errEl = document.getElementById('error-msg');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '登录中...';
|
||||
errEl.style.display = 'none';
|
||||
|
||||
var username = document.getElementById('username').value;
|
||||
var password = document.getElementById('password').value;
|
||||
|
||||
try {
|
||||
var resp = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username, password: password })
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (resp.ok && data.success) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
errEl.textContent = data.message || '登录失败';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
errEl.textContent = '网络错误,请重试';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = '登 录';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+32
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=xterm-addon-fit.js.map
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(self,(()=>(()=>{"use strict";var e={6:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,i={}){this._terminal=e,this._regex=t,this._handler=n,this._options=i}provideLinks(e,t){const i=n.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(i))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:i}=e;this._options.hover(t,n,i)}},e)))}};class n{static computeLink(e,t,i,r){const o=new RegExp(t.source,(t.flags||"")+"g"),[s,a]=n._getWindowedLineStrings(e-1,i),c=s.join("");let d;const l=[];for(;d=o.exec(c);){const e=d[0];try{const t=new URL(e),n=decodeURI(t.toString());if(e!==n&&e+"/"!==n)continue}catch(e){continue}const[t,o]=n._mapStrIdx(i,a,0,d.index),[s,c]=n._mapStrIdx(i,t,o,e.length);if(-1===t||-1===o||-1===s||-1===c)continue;const p={start:{x:o+1,y:t+1},end:{x:c,y:s+1}};l.push({range:p,text:e,activate:r})}return l}static _getWindowedLineStrings(e,t){let n,i=e,r=e,o=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(o=0;(n=t.buffer.active.getLine(--i))&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),o=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,i]}static _mapStrIdx(e,t,n,i){const r=e.buffer.active,o=r.getNullCell();let s=n;for(;i;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,o);const s=o.getChars();if(o.getWidth()&&(i-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,o),2===o.getWidth()&&(i+=1))}if(i<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=n}},t={};function n(i){var r=t[i];if(void 0!==r)return r.exports;var o=t[i]={exports:{}};return e[i](o,o.exports,n),o.exports}var i={};return(()=>{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(6),r=/https?:[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function o(e,t){const n=window.open();if(n){try{n.opener=null}catch(e){}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=o,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,i=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,i,this._handler,n))}dispose(){var e;null===(e=this._linkProvider)||void 0===e||e.dispose()}}})(),i})()));
|
||||
//# sourceMappingURL=xterm-addon-web-links.js.map
|
||||
Vendored
+209
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user