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

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