1589 lines
74 KiB
JavaScript
1589 lines
74 KiB
JavaScript
/**
|
|
* NetBrain MCP 终端管理器
|
|
*
|
|
* 基于XTerm.js实现虚拟终端功能
|
|
* 通过WebSocket与后端scrapli连接器通信
|
|
*/
|
|
|
|
class TerminalManager {
|
|
constructor() {
|
|
this.terminals = [];
|
|
this.activeTerminalId = null;
|
|
this.tabContainer = document.querySelector('.terminal-tabs');
|
|
this.windowContainer = document.querySelector('.terminal-windows');
|
|
this.nextId = 1;
|
|
|
|
// 命令自动补全功能所需的通用命令列表
|
|
this.commonCommands = {
|
|
'cisco': [
|
|
// 基本命令
|
|
'show', 'configure', 'terminal', 'interface', 'ip', 'router', 'enable', 'disable',
|
|
'exit', 'end', 'write', 'reload', 'ping', 'traceroute', 'telnet', 'ssh',
|
|
'copy', 'delete', 'dir', 'more', 'no', 'clear', 'clock', 'hostname',
|
|
|
|
// 配置命令
|
|
'configure terminal', 'line console', 'line vty', 'access-list', 'banner',
|
|
'logging', 'service', 'boot', 'cdp', 'lldp', 'vtp', 'spanning-tree',
|
|
'username', 'password', 'enable secret', 'enable password', 'crypto',
|
|
'aaa', 'tacacs', 'radius', 'ntp', 'snmp-server', 'ip http', 'ip domain',
|
|
'ip name-server', 'ip ssh', 'ip access-list', 'ip route', 'vlan',
|
|
'switchport', 'channel-group', 'port-channel', 'standby', 'vrf',
|
|
'policy-map', 'class-map', 'route-map', 'ip nat', 'ip dhcp',
|
|
|
|
// 显示命令
|
|
'show running-config', 'show startup-config', 'show version', 'show ip interface brief',
|
|
'show ip route', 'show vlan', 'show mac address-table', 'show cdp neighbors',
|
|
'show interfaces status', 'show interfaces', 'show ip protocols',
|
|
'show processes cpu', 'show processes memory', 'show environment',
|
|
'show inventory', 'show tech-support', 'show logging', 'show users',
|
|
'show clock', 'show vtp status', 'show spanning-tree', 'show standby',
|
|
'show etherchannel summary', 'show ip ospf', 'show ip bgp',
|
|
'show ip nat translations', 'show ip dhcp binding', 'show access-lists',
|
|
'show controllers', 'show platform', 'show diagnostic', 'show power',
|
|
'show module', 'show license', 'show switch', 'show stack',
|
|
'show vrf', 'show mpls', 'show crypto', 'show aaa', 'show tacacs',
|
|
'show radius', 'show ntp', 'show snmp', 'show ip ssh',
|
|
|
|
// 接口命令
|
|
'interface GigabitEthernet', 'interface FastEthernet', 'interface TenGigabitEthernet',
|
|
'interface FortyGigabitEthernet', 'interface HundredGigE', 'interface Loopback',
|
|
'interface Tunnel', 'interface Vlan', 'interface Port-channel',
|
|
'interface GigabitEthernet0/0', 'interface GigabitEthernet0/0/0',
|
|
'interface FastEthernet0/0', 'interface TenGigabitEthernet1/1',
|
|
'interface Vlan1', 'interface Loopback0', 'interface Port-channel1',
|
|
|
|
// 接口配置命令
|
|
'switchport mode access', 'switchport mode trunk', 'switchport access vlan',
|
|
'switchport trunk allowed vlan', 'switchport trunk encapsulation',
|
|
'switchport nonegotiate', 'switchport port-security', 'spanning-tree portfast',
|
|
'spanning-tree bpduguard', 'channel-group mode', 'ip address', 'ip helper-address',
|
|
'duplex', 'speed', 'negotiation', 'mtu', 'bandwidth', 'delay',
|
|
'shutdown', 'no shutdown', 'description', 'encapsulation', 'keepalive',
|
|
'load-interval', 'carrier-delay', 'flow-control', 'mdix auto',
|
|
|
|
// 路由协议命令
|
|
'router ospf', 'router bgp', 'router eigrp', 'router isis', 'router rip',
|
|
'network', 'area', 'passive-interface', 'default-information originate',
|
|
'redistribute', 'neighbor', 'distance', 'maximum-paths', 'auto-cost',
|
|
|
|
// 其他常用命令
|
|
'do', 'debug', 'undebug', 'erase', 'archive', 'verify', 'test',
|
|
'macro', 'monitor', 'diagnostic', 'license', 'platform', 'redundancy'
|
|
],
|
|
'huawei': [
|
|
// 基本系统命令
|
|
'display', 'system-view', 'interface', 'ip', 'quit', 'return', 'save',
|
|
'reboot', 'ping', 'tracert', 'telnet', 'ssh', 'copy', 'delete',
|
|
'dir', 'more', 'undo', 'sysname', 'header', 'clock', 'debugging',
|
|
|
|
// 系统视图命令
|
|
'aaa', 'acl', 'user-interface', 'user', 'super', 'password', 'role',
|
|
'snmp-agent', 'vlan', 'vlan batch', 'port-group', 'diffserv', 'domain',
|
|
'stp', 'lacp', 'ntp', 'info-center', 'arp', 'ospf', 'isis', 'bgp',
|
|
'route-policy', 'ip route-static', 'mpls', 'mpls lsp', 'qos', 'traffic',
|
|
'radius-server', 'hwtacacs-server', 'local-user', 'ssh user', 'ftp',
|
|
'version', 'stelnet', 'public-key', 'rsa', 'dsa', 'license', 'patch',
|
|
|
|
// 详细配置命令
|
|
'ospf 1', 'area', 'network', 'vrrp vrid', 'track', 'dhcp enable',
|
|
'dhcp server', 'dhcp pool', 'gateway-list', 'dns-list', 'interface vlanif',
|
|
'nat', 'nat server', 'nat outbound', 'port link-type', 'port trunk allow-pass',
|
|
'port hybrid', 'port access', 'loopback', 'description', 'shutdown',
|
|
'undo shutdown', 'mtu', 'speed', 'duplex', 'flow-control', 'portswitch',
|
|
|
|
// 诊断和排障命令
|
|
'display ip routing-table', 'display ip interface brief',
|
|
'display current-configuration', 'display saved-configuration',
|
|
'display version', 'display device', 'display device interface',
|
|
'display arp', 'display mac-address', 'display cpu-usage', 'display memory-usage',
|
|
'display vlan', 'display port vlan', 'display port', 'display logbuffer',
|
|
'display diagnostic-information', 'display fan', 'display power',
|
|
'display temperature', 'display clock', 'display environment',
|
|
'display ntp status', 'display ntp session', 'display interface',
|
|
'display ip interface', 'display users', 'display ssh user-information',
|
|
'display tcp status', 'display ip socket', 'display ip statistics',
|
|
|
|
// 更多显示命令组合
|
|
'display interface brief', 'display ospf peer', 'display ospf lsdb',
|
|
'display ospf routing', 'display bgp peer', 'display bgp routing-table',
|
|
'display acl all', 'display acl resource', 'display traffic-policy',
|
|
'display qos-profile', 'display queue-statistics', 'display traffic classifier',
|
|
'display traffic behavior', 'display traffic-policy statistics',
|
|
'display ip vpn-instance', 'display ip vpn-instance interface',
|
|
'display mac-address blackhole', 'display mac-address dynamic',
|
|
'display mac-address static', 'display lldp neighbor', 'display lldp statistics',
|
|
'display device manuinfo', 'display device manuinfo fan',
|
|
'display device manuinfo power', 'display stack', 'display esn',
|
|
'display patch-information', 'display patch-status', 'display elabel',
|
|
'display transceiver', 'display transceiver verbose', 'display transceiver alarm',
|
|
'display transceiver diagnosis', 'display startup', 'display boot-loader',
|
|
'display dhcp server', 'display dhcp client', 'display dhcp pool',
|
|
'display license', 'display license feature', 'display license authorization',
|
|
'display mirror', 'display alarm', 'display alarm statistics',
|
|
'display sflow', 'display dldp', 'display nqa', 'display info-center',
|
|
'display link-aggregation', 'display port trunk', 'display port-security',
|
|
'display error-down', 'display interface counters', 'display interface statistics',
|
|
|
|
// 接口特定命令
|
|
'interface GigabitEthernet', 'interface XGigabitEthernet',
|
|
'interface 10GE', 'interface 40GE', 'interface 100GE',
|
|
'interface Eth-Trunk', 'interface LoopBack', 'interface MEth',
|
|
'interface Tunnel', 'interface NULL', 'interface Vlanif',
|
|
'interface GigabitEthernet0/0/1', 'interface XGigabitEthernet0/0/1',
|
|
'interface 10GE1/0/1', 'interface 40GE1/0/1', 'interface Eth-Trunk1',
|
|
'interface Vlanif100', 'interface LoopBack0', 'interface NULL0',
|
|
'interface Tunnel0/0/0',
|
|
|
|
// 接口详细配置命令
|
|
'portswitch', 'jumboframe enable', 'mdix auto', 'loopback',
|
|
'port link-type access', 'port link-type trunk', 'port link-type hybrid',
|
|
'port default vlan', 'port trunk allow-pass vlan', 'port hybrid tagged vlan',
|
|
'port hybrid untagged vlan', 'port hybrid pvid vlan', 'stp',
|
|
'stp edged-port enable', 'stp bpdu-protection', 'stp root-protection',
|
|
'stp loop-protection', 'port-security enable', 'port-security max-mac-num',
|
|
'port-security port-mode', 'port-security mac-address sticky',
|
|
'lldp enable', 'lldp transmit', 'lldp receive', 'undo negotiation auto',
|
|
'speed 1000', 'speed auto', 'duplex full', 'duplex auto',
|
|
'trust dscp', 'trust 8021p', 'traffic-filter', 'traffic-policy',
|
|
'qos-profile', 'mirroring-group', 'monitor-link group', 'ip binding vpn-instance',
|
|
'statistic enable', 'flow-control', 'flow-control receive',
|
|
|
|
// 调试命令
|
|
'debugging ip packet', 'debugging dhcp', 'debugging ssh', 'debugging ntp',
|
|
'debugging ospf packet', 'debugging bgp', 'debugging igmp', 'debugging pim',
|
|
|
|
// 安全与访问控制命令
|
|
'acl number', 'acl name', 'rule', 'firewall', 'security-policy',
|
|
'ike', 'ipsec', 'ssl', 'pki', 'ca', 'certificate', 'aaa',
|
|
|
|
// 特定撤销命令
|
|
'undo ip address', 'undo vlan', 'undo interface', 'undo acl', 'undo stp',
|
|
'undo dhcp', 'undo nat', 'undo ip route-static', 'undo ospf',
|
|
'undo bgp', 'undo snmp-agent', 'undo ssh user', 'undo user-interface',
|
|
'undo local-user', 'undo mirroring-group', 'undo traffic-policy',
|
|
'undo qos-profile', 'undo port trunk', 'undo port hybrid',
|
|
'undo port link-type', 'undo port default vlan', 'undo stp enable',
|
|
'undo lldp enable', 'undo info-center', 'undo ntp-service',
|
|
'undo ip vpn-instance', 'undo sysname', 'undo license',
|
|
|
|
// 配置保存和重启命令
|
|
'save', 'save force', 'save safely', 'reboot', 'reboot fast',
|
|
'startup saved-configuration', 'reset saved-configuration',
|
|
'reset recycle-bin', 'reset counters interface', 'reset logbuffer',
|
|
|
|
// VPN和MPLS相关命令
|
|
'ip vpn-instance', 'ipv4-family', 'ipv6-family', 'route-distinguisher',
|
|
'vpn-target', 'mpls lsr-id', 'mpls', 'mpls ldp', 'mpls te',
|
|
'mpls rsvp-te', 'tunnel-protocol', 'mpls l2vc', 'mpls l3vpn',
|
|
|
|
// 特定场景配置命令
|
|
'smart-link group', 'smart-link flush enable', 'monitor-link group',
|
|
'vrrp vrid', 'vrrp authentication-mode', 'vrrp preempt-mode',
|
|
'vrrp track', 'track', 'track nqa', 'track interface',
|
|
'hrp enable', 'hrp interface', 'dfs-group', 'stack',
|
|
'issu', 'issu load', 'issu run', 'issu accept', 'issu commit',
|
|
'issu set', 'patch', 'patch active', 'patch deactive', 'patch delete',
|
|
'patch install', 'patch rollback', 'patch running', 'startup patch',
|
|
|
|
// 常用缩写命令
|
|
'dis', 'int', 'sys', 'sa', 'qu', 'ret', 'conf', 'cl', 'un'
|
|
],
|
|
'common': [
|
|
'ls', 'cd', 'pwd', 'cat', 'clear', 'help', 'exit', 'quit'
|
|
]
|
|
};
|
|
|
|
// 绑定事件处理程序
|
|
document.getElementById('reconnect-terminal')?.addEventListener('click', () => this.reconnectActiveTerminal());
|
|
document.getElementById('connect-device')?.addEventListener('click', () => this.showConnectDialog());
|
|
|
|
// 绑定连接对话框事件
|
|
document.getElementById('connect-form')?.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.handleDeviceConnect();
|
|
});
|
|
|
|
// 绑定窗口调整大小事件
|
|
window.addEventListener('resize', () => {
|
|
this.resizeActiveTerminal();
|
|
});
|
|
|
|
// 初始化连接对话框关闭按钮
|
|
const closeButtons = document.querySelectorAll('.modal .close, .modal .close-btn');
|
|
closeButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.modal').forEach(modal => {
|
|
modal.style.display = 'none';
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// 创建新终端
|
|
createTerminal(name, deviceId = null, credentialId = null) {
|
|
const terminalId = `terminal-${this.nextId++}`;
|
|
|
|
// 创建标签页
|
|
const tab = document.createElement('div');
|
|
tab.className = 'terminal-tab';
|
|
tab.dataset.target = terminalId;
|
|
tab.innerHTML = `
|
|
<span>${name}</span>
|
|
<span class="terminal-tab-close">×</span>
|
|
`;
|
|
this.tabContainer.appendChild(tab);
|
|
|
|
// 创建终端窗口
|
|
const termWindow = document.createElement('div');
|
|
termWindow.className = 'terminal-instance';
|
|
termWindow.id = terminalId;
|
|
this.windowContainer.appendChild(termWindow);
|
|
|
|
// 初始化xterm.js终端
|
|
const terminal = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: 14,
|
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
theme: {
|
|
background: '#1a1a1a',
|
|
foreground: '#f8f8f8'
|
|
},
|
|
allowTransparency: true,
|
|
scrollback: 5000, // 增大滚动缓冲区
|
|
lineHeight: 1.2, // 增加行间距
|
|
rendererType: 'canvas', // 使用canvas渲染器提升性能
|
|
convertEol: true, // 确保回车时换行
|
|
letterSpacing: 0, // 调整字间距
|
|
tabStopWidth: 8 // Tab宽度
|
|
});
|
|
|
|
// 使用FitAddon来自适应终端大小
|
|
const fitAddon = new FitAddon.FitAddon();
|
|
terminal.loadAddon(fitAddon);
|
|
|
|
// 使用WebLinksAddon自动识别链接
|
|
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
|
terminal.loadAddon(webLinksAddon);
|
|
|
|
// 打开终端并适配大小
|
|
terminal.open(termWindow);
|
|
setTimeout(() => fitAddon.fit(), 100);
|
|
|
|
// 保存终端信息
|
|
const terminalInfo = {
|
|
id: terminalId,
|
|
name: name,
|
|
terminal: terminal,
|
|
fitAddon: fitAddon,
|
|
deviceId: deviceId,
|
|
credentialId: credentialId,
|
|
socket: null,
|
|
commandBuffer: '',
|
|
cursorPosition: 0, // 跟踪光标在命令中的位置
|
|
inputActive: false, // 标记终端是否正在接收用户输入
|
|
sessionRegistered: false, // 会话注册标记
|
|
commandHistory: [], // 命令历史数组
|
|
historyIndex: -1, // 历史命令索引
|
|
currentCommand: '' // 当前未提交的命令
|
|
};
|
|
|
|
this.terminals.push(terminalInfo);
|
|
|
|
// 设置事件监听
|
|
// 点击标签页激活对应终端
|
|
tab.addEventListener('click', (e) => {
|
|
if (!e.target.classList.contains('terminal-tab-close')) {
|
|
this.activateTerminal(terminalId);
|
|
}
|
|
});
|
|
|
|
// 点击关闭按钮
|
|
tab.querySelector('.terminal-tab-close').addEventListener('click', () => {
|
|
this.closeTerminal(terminalId);
|
|
});
|
|
|
|
// 激活新创建的终端
|
|
this.activateTerminal(terminalId);
|
|
|
|
// 如果提供了设备ID和凭据ID,则连接到设备
|
|
if (deviceId && credentialId) {
|
|
this.connectToDevice(terminalInfo, deviceId, credentialId);
|
|
|
|
// 注册会话 - 使用terminalId作为会话ID
|
|
if (window.sessionManager) {
|
|
const deviceName = this._getDeviceName(deviceId);
|
|
window.sessionManager.registerActiveSession(terminalId, {
|
|
name: name || `${deviceName || '设备'}-会话`,
|
|
deviceId: deviceId,
|
|
credentialId: credentialId
|
|
});
|
|
|
|
// 记录注册的会话ID,以防重复注册
|
|
terminalInfo.sessionRegistered = true;
|
|
}
|
|
} else {
|
|
// 否则,显示本地终端提示
|
|
terminal.writeln('\r\n欢迎使用 NetBrain MCP 终端\r\n');
|
|
terminal.writeln('这是一个本地终端会话。要连接到网络设备,请点击"连接设备"按钮。\r\n');
|
|
|
|
// 创建简单的本地echo会话
|
|
terminalInfo.promptText = '[MCP]>';
|
|
terminal.write(`${terminalInfo.promptText}`);
|
|
terminalInfo.inputActive = true;
|
|
terminalInfo.cursorPosition = 0; // 初始化光标位置
|
|
terminalInfo.vendorType = 'common'; // 本地终端使用通用命令
|
|
|
|
terminal.onData(data => {
|
|
// 模拟简单的本地shell行为
|
|
// 处理上下箭头键
|
|
if (data === '\x1b[A') { // 上箭头
|
|
this.navigateHistory(terminalInfo, 'up');
|
|
return;
|
|
} else if (data === '\x1b[B') { // 下箭头
|
|
this.navigateHistory(terminalInfo, 'down');
|
|
return;
|
|
} else if (data === '\x1b[C') { // 右箭头
|
|
this.moveCursor(terminalInfo, 'right');
|
|
return;
|
|
} else if (data === '\x1b[D') { // 左箭头
|
|
this.moveCursor(terminalInfo, 'left');
|
|
return;
|
|
} else if (data === '\t') { // Tab键 - 处理自动补全
|
|
this.handleTabCompletion(terminalInfo);
|
|
return;
|
|
} else if (data === '\r') { // 回车
|
|
// 重置Tab补全状态
|
|
terminalInfo.tabMatches = null;
|
|
terminalInfo.tabIndex = undefined;
|
|
|
|
const command = terminalInfo.commandBuffer.trim();
|
|
|
|
// 添加到历史记录
|
|
if (command && (terminalInfo.commandHistory.length === 0 ||
|
|
terminalInfo.commandHistory[terminalInfo.commandHistory.length - 1] !== command)) {
|
|
terminalInfo.commandHistory.push(command);
|
|
// 限制历史记录长度
|
|
if (terminalInfo.commandHistory.length > 100) {
|
|
terminalInfo.commandHistory.shift();
|
|
}
|
|
}
|
|
// 重置历史索引和光标位置
|
|
terminalInfo.historyIndex = -1;
|
|
terminalInfo.cursorPosition = 0;
|
|
|
|
terminalInfo.commandBuffer = '';
|
|
terminal.writeln('');
|
|
|
|
// 简单的命令处理
|
|
if (command === 'clear') {
|
|
terminal.clear();
|
|
} else if (command === 'help') {
|
|
terminal.writeln('可用命令:');
|
|
terminal.writeln(' clear - 清屏');
|
|
terminal.writeln(' help - 显示帮助');
|
|
terminal.writeln(' exit - 关闭终端');
|
|
terminal.writeln(' connect - 连接到设备');
|
|
terminal.writeln(' tab键 - 自动补全命令');
|
|
terminal.writeln(' sysname - 修改终端名称 (例如: sysname Router1)');
|
|
} else if (command === 'exit') {
|
|
this.closeTerminal(terminalId);
|
|
return;
|
|
} else if (command === 'connect') {
|
|
this.showConnectDialog();
|
|
} else if (command.startsWith('sysname ')) {
|
|
const newName = command.substring(8).trim();
|
|
if (newName) {
|
|
terminalInfo.promptText = `[${newName}]>`;
|
|
terminal.writeln(`终端名称已修改为: ${newName}`);
|
|
} else {
|
|
terminal.writeln('请提供终端名称');
|
|
}
|
|
} else if (command) {
|
|
terminal.writeln(`未知命令: ${command}`);
|
|
}
|
|
|
|
terminal.write(`${terminalInfo.promptText}`);
|
|
} else if (data === '\u007f') { // 退格键
|
|
if (terminalInfo.commandBuffer.length > 0 && terminalInfo.cursorPosition > 0) {
|
|
// 重置Tab补全状态
|
|
terminalInfo.tabMatches = null;
|
|
terminalInfo.tabIndex = undefined;
|
|
|
|
// 在光标位置删除字符
|
|
const before = terminalInfo.commandBuffer.substring(0, terminalInfo.cursorPosition - 1);
|
|
const after = terminalInfo.commandBuffer.substring(terminalInfo.cursorPosition);
|
|
terminalInfo.commandBuffer = before + after;
|
|
terminalInfo.cursorPosition--;
|
|
|
|
// 重新绘制整行
|
|
terminal.write('\b \b'); // 删除光标前的字符
|
|
|
|
// 如果光标不在行尾,需要重新绘制整个命令
|
|
if (terminalInfo.cursorPosition < terminalInfo.commandBuffer.length) {
|
|
// 将光标移回行首
|
|
terminal.write('\b'.repeat(terminalInfo.cursorPosition));
|
|
|
|
// 清空整行
|
|
terminal.write(' '.repeat(terminalInfo.commandBuffer.length + 1));
|
|
terminal.write('\b'.repeat(terminalInfo.commandBuffer.length + 1));
|
|
|
|
// 写入新命令
|
|
terminal.write(terminalInfo.commandBuffer);
|
|
|
|
// 将光标移动到新位置
|
|
terminal.write('\b'.repeat(terminalInfo.commandBuffer.length - terminalInfo.cursorPosition));
|
|
}
|
|
}
|
|
} else {
|
|
// 输入了新字符,重置Tab补全状态
|
|
terminalInfo.tabMatches = null;
|
|
terminalInfo.tabIndex = undefined;
|
|
|
|
// 在光标位置插入字符
|
|
const before = terminalInfo.commandBuffer.substring(0, terminalInfo.cursorPosition);
|
|
const after = terminalInfo.commandBuffer.substring(terminalInfo.cursorPosition);
|
|
terminalInfo.commandBuffer = before + data + after;
|
|
terminalInfo.cursorPosition++;
|
|
|
|
// 如果光标在行尾,直接写入字符
|
|
if (terminalInfo.cursorPosition === terminalInfo.commandBuffer.length) {
|
|
terminal.write(data);
|
|
} else {
|
|
// 否则,需要重新绘制整行
|
|
terminal.write(data + after);
|
|
|
|
// 将光标移回到正确位置
|
|
terminal.write('\b'.repeat(after.length));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return terminalInfo;
|
|
}
|
|
|
|
// 移动光标
|
|
moveCursor(terminalInfo, direction) {
|
|
const terminal = terminalInfo.terminal;
|
|
|
|
if (direction === 'left') {
|
|
// 光标向左移动,但不能超出命令范围
|
|
if (terminalInfo.cursorPosition > 0) {
|
|
terminalInfo.cursorPosition--;
|
|
terminal.write('\x1b[D'); // 向左移动光标
|
|
}
|
|
} else if (direction === 'right') {
|
|
// 光标向右移动,但不能超出命令末尾
|
|
if (terminalInfo.cursorPosition < terminalInfo.commandBuffer.length) {
|
|
terminalInfo.cursorPosition++;
|
|
terminal.write('\x1b[C'); // 向右移动光标
|
|
}
|
|
}
|
|
}
|
|
|
|
// 清除输入行
|
|
clearInputLine(terminalInfo) {
|
|
const terminal = terminalInfo.terminal;
|
|
const currentLength = terminalInfo.commandBuffer.length;
|
|
|
|
if (currentLength > 0) {
|
|
// 先将光标移到行尾,然后再清除整行
|
|
if (terminalInfo.cursorPosition < currentLength) {
|
|
terminal.write('\x1b[C'.repeat(currentLength - terminalInfo.cursorPosition));
|
|
}
|
|
terminal.write('\b'.repeat(currentLength) + ' '.repeat(currentLength) + '\b'.repeat(currentLength));
|
|
}
|
|
}
|
|
|
|
// 导航命令历史功能
|
|
navigateHistory(terminalInfo, direction) {
|
|
const terminal = terminalInfo.terminal;
|
|
const history = terminalInfo.commandHistory;
|
|
|
|
if (history.length === 0) return;
|
|
|
|
// 处理上箭头
|
|
if (direction === 'up') {
|
|
// 第一次按上箭头时,保存当前输入
|
|
if (terminalInfo.historyIndex === -1) {
|
|
terminalInfo.currentCommand = terminalInfo.commandBuffer;
|
|
}
|
|
|
|
// 确保不超出历史记录范围
|
|
if (terminalInfo.historyIndex < history.length - 1) {
|
|
terminalInfo.historyIndex++;
|
|
|
|
// 清除当前行
|
|
this.clearInputLine(terminalInfo);
|
|
|
|
// 显示历史命令
|
|
const historyCommand = history[history.length - 1 - terminalInfo.historyIndex];
|
|
terminal.write(historyCommand);
|
|
terminalInfo.commandBuffer = historyCommand;
|
|
terminalInfo.cursorPosition = historyCommand.length; // 更新光标位置到行尾
|
|
}
|
|
}
|
|
// 处理下箭头
|
|
else if (direction === 'down') {
|
|
if (terminalInfo.historyIndex > -1) {
|
|
terminalInfo.historyIndex--;
|
|
|
|
// 清除当前行
|
|
this.clearInputLine(terminalInfo);
|
|
|
|
// 如果回到初始状态,显示未提交的命令
|
|
if (terminalInfo.historyIndex === -1) {
|
|
terminal.write(terminalInfo.currentCommand);
|
|
terminalInfo.commandBuffer = terminalInfo.currentCommand;
|
|
terminalInfo.cursorPosition = terminalInfo.currentCommand.length; // 更新光标位置到行尾
|
|
} else {
|
|
// 否则显示较新的历史命令
|
|
const historyCommand = history[history.length - 1 - terminalInfo.historyIndex];
|
|
terminal.write(historyCommand);
|
|
terminalInfo.commandBuffer = historyCommand;
|
|
terminalInfo.cursorPosition = historyCommand.length; // 更新光标位置到行尾
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 获取设备名称
|
|
_getDeviceName(deviceId) {
|
|
const cachedDevices = window.cachedDevices || [];
|
|
const device = cachedDevices.find(d => d.id === deviceId);
|
|
return device ? device.name : null;
|
|
}
|
|
|
|
// 激活终端
|
|
activateTerminal(terminalId) {
|
|
// 取消激活所有终端
|
|
document.querySelectorAll('.terminal-tab').forEach(tab => {
|
|
tab.classList.remove('active');
|
|
});
|
|
document.querySelectorAll('.terminal-instance').forEach(inst => {
|
|
inst.classList.remove('active');
|
|
});
|
|
|
|
// 激活选定的终端
|
|
const tab = document.querySelector(`.terminal-tab[data-target="${terminalId}"]`);
|
|
const instance = document.getElementById(terminalId);
|
|
|
|
if (tab && instance) {
|
|
tab.classList.add('active');
|
|
instance.classList.add('active');
|
|
this.activeTerminalId = terminalId;
|
|
|
|
// 获取终端对象并重新适应大小
|
|
this.resizeActiveTerminal();
|
|
}
|
|
}
|
|
|
|
// 调整活动终端大小
|
|
resizeActiveTerminal() {
|
|
if (this.activeTerminalId) {
|
|
const termInfo = this.getTerminalById(this.activeTerminalId);
|
|
if (termInfo && termInfo.fitAddon) {
|
|
setTimeout(() => termInfo.fitAddon.fit(), 10);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 关闭终端
|
|
closeTerminal(terminalId) {
|
|
// 获取终端信息
|
|
const termIndex = this.terminals.findIndex(t => t.id === terminalId);
|
|
if (termIndex === -1) return;
|
|
|
|
const termInfo = this.terminals[termIndex];
|
|
|
|
// 标记为手动关闭,防止自动重连
|
|
termInfo.manualClosed = true;
|
|
termInfo.needReconnect = false;
|
|
|
|
// 清除任何重连或超时计时器
|
|
if (termInfo.reconnectTimer) {
|
|
clearTimeout(termInfo.reconnectTimer);
|
|
termInfo.reconnectTimer = null;
|
|
}
|
|
|
|
if (termInfo.inputTimeoutId) {
|
|
clearTimeout(termInfo.inputTimeoutId);
|
|
termInfo.inputTimeoutId = null;
|
|
}
|
|
|
|
// 关闭WebSocket连接
|
|
if (termInfo.socket && termInfo.socket.readyState === WebSocket.OPEN) {
|
|
termInfo.socket.close();
|
|
}
|
|
|
|
// 从DOM移除元素
|
|
const tab = document.querySelector(`.terminal-tab[data-target="${terminalId}"]`);
|
|
const instance = document.getElementById(terminalId);
|
|
|
|
if (tab) tab.remove();
|
|
if (instance) instance.remove();
|
|
|
|
// 销毁终端实例
|
|
termInfo.terminal.dispose();
|
|
|
|
// 从数组中移除
|
|
this.terminals.splice(termIndex, 1);
|
|
|
|
// 如果关闭的是当前激活的终端,则激活另一个终端
|
|
if (this.activeTerminalId === terminalId && this.terminals.length > 0) {
|
|
this.activateTerminal(this.terminals[0].id);
|
|
} else if (this.terminals.length === 0) {
|
|
this.activeTerminalId = null;
|
|
}
|
|
|
|
// 注销会话
|
|
if (window.sessionManager && termInfo.sessionRegistered) {
|
|
window.sessionManager.unregisterActiveSession(terminalId);
|
|
}
|
|
}
|
|
|
|
// 通过ID获取终端信息
|
|
getTerminalById(terminalId) {
|
|
return this.terminals.find(t => t.id === terminalId);
|
|
}
|
|
|
|
// 显示连接设备对话框
|
|
showConnectDialog() {
|
|
// 显示模态框
|
|
const modal = document.getElementById('device-connect-modal');
|
|
modal.style.display = 'block';
|
|
|
|
const deviceSelect = document.getElementById('device-select');
|
|
const credentialSelect = document.getElementById('credential-select');
|
|
|
|
// 清空下拉列表并显示加载中状态
|
|
deviceSelect.innerHTML = '<option value="">-- 正在加载设备列表... --</option>';
|
|
credentialSelect.innerHTML = '<option value="">-- 正在加载凭据列表... --</option>';
|
|
|
|
// 如果有缓存数据,优先使用缓存
|
|
if (window.cachedDevices && window.cachedDevices.length > 0) {
|
|
this.updateDeviceSelectOptions(deviceSelect, window.cachedDevices);
|
|
}
|
|
|
|
if (window.cachedCredentials && window.cachedCredentials.length > 0) {
|
|
this.updateCredentialSelectOptions(credentialSelect, window.cachedCredentials);
|
|
}
|
|
|
|
// 无论是否有缓存,都重新请求最新数据
|
|
// 加载设备列表
|
|
console.log('正在加载设备列表...');
|
|
fetch('/api/devices')
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP错误 ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(devices => {
|
|
console.log('设备列表加载成功:', devices);
|
|
window.cachedDevices = devices; // 更新缓存
|
|
this.updateDeviceSelectOptions(deviceSelect, devices);
|
|
})
|
|
.catch(error => {
|
|
console.error('加载设备列表失败:', error);
|
|
if (!window.cachedDevices || window.cachedDevices.length === 0) {
|
|
deviceSelect.innerHTML = '<option value="">-- 加载设备列表失败 --</option>';
|
|
alert('加载设备列表失败: ' + error.message);
|
|
}
|
|
});
|
|
|
|
// 加载凭据列表
|
|
console.log('正在加载凭据列表...');
|
|
fetch('/api/credentials')
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP错误 ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(credentials => {
|
|
console.log('凭据列表加载成功:', credentials);
|
|
window.cachedCredentials = credentials; // 更新缓存
|
|
this.updateCredentialSelectOptions(credentialSelect, credentials);
|
|
})
|
|
.catch(error => {
|
|
console.error('加载凭据列表失败:', error);
|
|
if (!window.cachedCredentials || window.cachedCredentials.length === 0) {
|
|
credentialSelect.innerHTML = '<option value="">-- 加载凭据列表失败 --</option>';
|
|
alert('加载凭据列表失败: ' + error.message);
|
|
}
|
|
});
|
|
|
|
// 设备选择变化时更新终端名称默认值
|
|
deviceSelect.addEventListener('change', () => {
|
|
const selectedOption = deviceSelect.selectedOptions[0];
|
|
const terminalNameInput = document.getElementById('terminal-name');
|
|
|
|
if (selectedOption && selectedOption.value) {
|
|
const deviceName = selectedOption.textContent.split(' ')[0];
|
|
terminalNameInput.value = `${deviceName}-会话`;
|
|
}
|
|
});
|
|
}
|
|
|
|
// 更新设备选择下拉列表
|
|
updateDeviceSelectOptions(deviceSelect, devices) {
|
|
deviceSelect.innerHTML = '<option value="">-- 请选择设备 --</option>';
|
|
|
|
if (!devices || devices.length === 0) {
|
|
deviceSelect.innerHTML = '<option value="">-- 没有可用设备 --</option>';
|
|
console.log('没有可用设备');
|
|
return;
|
|
}
|
|
|
|
devices.forEach(device => {
|
|
const option = document.createElement('option');
|
|
option.value = device.id;
|
|
option.textContent = `${device.name} (${device.ip_address})`;
|
|
deviceSelect.appendChild(option);
|
|
});
|
|
}
|
|
|
|
// 更新凭据选择下拉列表
|
|
updateCredentialSelectOptions(credentialSelect, credentials) {
|
|
credentialSelect.innerHTML = '<option value="">-- 请选择凭据 --</option>';
|
|
|
|
if (!credentials || credentials.length === 0) {
|
|
credentialSelect.innerHTML = '<option value="">-- 没有可用凭据 --</option>';
|
|
console.log('没有可用凭据');
|
|
return;
|
|
}
|
|
|
|
credentials.forEach(credential => {
|
|
const option = document.createElement('option');
|
|
option.value = credential.id;
|
|
option.textContent = `${credential.name} (${credential.username}@${credential.protocol})`;
|
|
credentialSelect.appendChild(option);
|
|
});
|
|
}
|
|
|
|
// 处理连接设备表单提交
|
|
handleDeviceConnect() {
|
|
const deviceId = document.getElementById('device-select').value;
|
|
const credentialId = document.getElementById('credential-select').value;
|
|
const terminalName = document.getElementById('terminal-name').value;
|
|
|
|
// 验证表单
|
|
if (!deviceId) {
|
|
alert('请选择设备');
|
|
return;
|
|
}
|
|
|
|
if (!credentialId) {
|
|
alert('请选择凭据');
|
|
return;
|
|
}
|
|
|
|
if (!terminalName) {
|
|
alert('请输入终端会话名称');
|
|
return;
|
|
}
|
|
|
|
// 创建唯一终端ID,确保不重复
|
|
const uniqueId = `terminal-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
|
|
|
// 创建新终端并连接
|
|
const terminalInfo = this.createTerminal(terminalName, deviceId, credentialId);
|
|
|
|
// 隐藏对话框
|
|
document.getElementById('device-connect-modal').style.display = 'none';
|
|
|
|
return terminalInfo;
|
|
}
|
|
|
|
// 连接到设备
|
|
connectToDevice(terminalInfo, deviceId, credentialId) {
|
|
const terminal = terminalInfo.terminal;
|
|
|
|
// 显示连接中状态
|
|
terminal.writeln('\r\n正在连接到设备,请稍候...\r\n');
|
|
|
|
// 创建WebSocket连接
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${window.location.host}/ws/terminal/${deviceId}/${credentialId}`;
|
|
|
|
console.log(`连接到WebSocket: ${wsUrl}`);
|
|
|
|
const socket = new WebSocket(wsUrl);
|
|
terminalInfo.socket = socket;
|
|
terminalInfo.manualClosed = false; // 初始化为非手动关闭
|
|
|
|
// 确保终端有命令历史相关属性
|
|
if (!terminalInfo.commandHistory) {
|
|
terminalInfo.commandHistory = [];
|
|
}
|
|
if (terminalInfo.historyIndex === undefined) {
|
|
terminalInfo.historyIndex = -1;
|
|
}
|
|
if (!terminalInfo.currentCommand) {
|
|
terminalInfo.currentCommand = '';
|
|
}
|
|
if (terminalInfo.cursorPosition === undefined) {
|
|
terminalInfo.cursorPosition = 0;
|
|
}
|
|
|
|
// 尝试获取设备类型用于命令补全
|
|
if (deviceId && window.cachedDevices) {
|
|
const deviceInfo = window.cachedDevices.find(d => d.id === deviceId);
|
|
if (deviceInfo && deviceInfo.vendor) {
|
|
terminalInfo.vendorType = deviceInfo.vendor.toLowerCase();
|
|
console.log(`设备厂商类型: ${terminalInfo.vendorType}`);
|
|
}
|
|
}
|
|
|
|
// 设置WebSocket事件处理器
|
|
this.setupSocketHandlers(terminalInfo, deviceId, credentialId);
|
|
|
|
// 保存设备连接的信息,用于可能的重连
|
|
terminalInfo.deviceId = deviceId;
|
|
terminalInfo.credentialId = credentialId;
|
|
}
|
|
|
|
// 设置WebSocket事件处理器(用于初始连接和重连)
|
|
setupSocketHandlers(terminalInfo, deviceId, credentialId) {
|
|
const terminal = terminalInfo.terminal;
|
|
const socket = terminalInfo.socket;
|
|
|
|
socket.onopen = () => {
|
|
terminal.writeln('WebSocket连接已建立,等待设备响应...\r\n');
|
|
terminalInfo.needReconnect = false; // 连接成功,重置重连标志
|
|
|
|
// 重置输入状态
|
|
terminalInfo.commandBuffer = '';
|
|
terminalInfo.cursorPosition = 0;
|
|
// 注意:此处不设置inputActive=true,等待收到设备响应后再激活输入
|
|
};
|
|
|
|
socket.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
console.log('收到WebSocket消息:', data.type);
|
|
|
|
if (data.type === 'output') {
|
|
// 处理和格式化设备返回的内容
|
|
let content = data.content || '';
|
|
|
|
// 确保内容有正确的换行格式
|
|
// 替换所有可能的换行符组合为标准的\r\n
|
|
content = content.replace(/\r\n|\n\r|\n|\r/g, '\r\n');
|
|
|
|
// 尝试从设备输出中提取提示符
|
|
// 支持华为设备<xxx>、思科设备xxx#、xxx>等多种格式
|
|
const promptRegex = /((?:\<[\w\-\u4e00-\u9fa5\s]+\>)|(?:\[[\w\-\u4e00-\u9fa5\s]+\])|(?:[\w\-\u4e00-\u9fa5]+[#>]))(?:\s*)$/;
|
|
const promptMatch = content.match(promptRegex);
|
|
if (promptMatch && promptMatch[1]) {
|
|
// 更新提示符为设备返回的真实提示符
|
|
terminalInfo.promptText = promptMatch[1];
|
|
console.log('从设备输出提取提示符:', terminalInfo.promptText);
|
|
}
|
|
|
|
// 直接输出设备返回的内容,确保不会丢失任何字符
|
|
terminal.write(content);
|
|
|
|
// 再次激活终端输入
|
|
terminalInfo.inputActive = true;
|
|
|
|
// 默认光标位置应该在用户输入的末尾
|
|
terminalInfo.cursorPosition = terminalInfo.commandBuffer.length;
|
|
console.log('终端输入已激活,命令缓冲区长度:', terminalInfo.commandBuffer.length, '光标位置:', terminalInfo.cursorPosition);
|
|
|
|
// 清除输入超时计时器
|
|
if (terminalInfo.inputTimeoutId) {
|
|
clearTimeout(terminalInfo.inputTimeoutId);
|
|
terminalInfo.inputTimeoutId = null;
|
|
}
|
|
|
|
// 如果会话管理器存在,更新状态为活跃
|
|
if (window.sessionManager) {
|
|
window.sessionManager.updateSessionStatus(terminalInfo.id, 'active');
|
|
}
|
|
} else if (data.type === 'status') {
|
|
if (data.connected) {
|
|
terminal.writeln(`\r\n已连接到设备: ${data.device_name}\r\n`);
|
|
|
|
// 如果后端返回了设备厂商信息,更新用于命令补全
|
|
if (data.vendor) {
|
|
terminalInfo.vendorType = data.vendor.toLowerCase();
|
|
console.log(`从服务器获取设备厂商类型: ${terminalInfo.vendorType}`);
|
|
}
|
|
|
|
// 设置默认提示符,等待设备输出中提取真实提示符
|
|
if (data.vendor && data.vendor.toLowerCase() === 'huawei') {
|
|
terminalInfo.promptText = '<' + data.device_name + '>';
|
|
} else if (data.vendor && data.vendor.toLowerCase() === 'cisco') {
|
|
terminalInfo.promptText = data.device_name + '#';
|
|
} else {
|
|
// 默认提示符格式
|
|
terminalInfo.promptText = '<' + data.device_name + '>';
|
|
}
|
|
|
|
terminalInfo.inputActive = true;
|
|
terminalInfo.commandBuffer = '';
|
|
terminalInfo.cursorPosition = 0;
|
|
console.log('设备已连接,重置命令缓冲区和光标位置');
|
|
|
|
// 移除可能存在的旧输入处理器以避免重复绑定
|
|
if (terminalInfo.inputHandler) {
|
|
console.log('重置旧的输入处理器');
|
|
terminal.onData(() => {}); // 清除旧的处理函数
|
|
}
|
|
|
|
// 设置终端输入监听
|
|
terminalInfo.inputHandler = (input) => {
|
|
if (!terminalInfo.inputActive || socket.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
|
|
// 处理上下箭头键
|
|
if (input === '\x1b[A') { // 上箭头
|
|
this.navigateHistory(terminalInfo, 'up');
|
|
return;
|
|
} else if (input === '\x1b[B') { // 下箭头
|
|
this.navigateHistory(terminalInfo, 'down');
|
|
return;
|
|
} else if (input === '\x1b[C') { // 右箭头
|
|
this.moveCursor(terminalInfo, 'right');
|
|
return;
|
|
} else if (input === '\x1b[D') { // 左箭头
|
|
this.moveCursor(terminalInfo, 'left');
|
|
return;
|
|
} else if (input === '\t') { // Tab键 - 处理自动补全
|
|
this.handleTabCompletion(terminalInfo);
|
|
return;
|
|
}
|
|
|
|
// 回车键处理 - 发送完整命令
|
|
if (input === '\r') {
|
|
// 重置Tab补全状态
|
|
terminalInfo.tabMatches = null;
|
|
terminalInfo.tabIndex = undefined;
|
|
|
|
const command = terminalInfo.commandBuffer;
|
|
|
|
// 添加到历史记录
|
|
if (command && (terminalInfo.commandHistory.length === 0 ||
|
|
terminalInfo.commandHistory[terminalInfo.commandHistory.length - 1] !== command)) {
|
|
terminalInfo.commandHistory.push(command);
|
|
// 限制历史记录长度
|
|
if (terminalInfo.commandHistory.length > 100) {
|
|
terminalInfo.commandHistory.shift();
|
|
}
|
|
}
|
|
// 重置历史索引
|
|
terminalInfo.historyIndex = -1;
|
|
terminalInfo.currentCommand = '';
|
|
terminalInfo.cursorPosition = 0;
|
|
|
|
// 发送当前缓冲区的命令并清空缓冲区
|
|
socket.send(JSON.stringify({
|
|
type: 'input',
|
|
content: terminalInfo.commandBuffer + '\n'
|
|
}));
|
|
|
|
// 写入换行符,但不再写入提示符,后端会发送提示符
|
|
terminal.write('\r\n');
|
|
|
|
// 清空命令缓冲区
|
|
terminalInfo.commandBuffer = '';
|
|
|
|
// 暂时禁用输入,直到收到新的数据
|
|
terminalInfo.inputActive = false;
|
|
|
|
// 添加超时自动重新激活输入
|
|
if (terminalInfo.inputTimeoutId) {
|
|
clearTimeout(terminalInfo.inputTimeoutId);
|
|
}
|
|
|
|
terminalInfo.inputTimeoutId = setTimeout(() => {
|
|
if (!terminalInfo.inputActive && socket.readyState === WebSocket.OPEN) {
|
|
console.log('命令输入超时未收到响应,自动激活输入');
|
|
terminalInfo.inputActive = true;
|
|
}
|
|
}, 10000);
|
|
}
|
|
// 退格键处理
|
|
else if (input === '\x7F') {
|
|
if (terminalInfo.commandBuffer.length > 0 && terminalInfo.cursorPosition > 0) {
|
|
// 重置Tab补全状态
|
|
terminalInfo.tabMatches = null;
|
|
terminalInfo.tabIndex = undefined;
|
|
|
|
// 在光标位置删除字符
|
|
const before = terminalInfo.commandBuffer.substring(0, terminalInfo.cursorPosition - 1);
|
|
const after = terminalInfo.commandBuffer.substring(terminalInfo.cursorPosition);
|
|
terminalInfo.commandBuffer = before + after;
|
|
terminalInfo.cursorPosition--;
|
|
|
|
// 重新绘制整行
|
|
terminal.write('\b \b'); // 删除光标前的字符
|
|
|
|
// 如果光标不在行尾,需要重新绘制整个命令
|
|
if (terminalInfo.cursorPosition < terminalInfo.commandBuffer.length) {
|
|
// 将光标移回行首
|
|
terminal.write('\b'.repeat(terminalInfo.cursorPosition));
|
|
|
|
// 清空整行
|
|
terminal.write(' '.repeat(terminalInfo.commandBuffer.length + 1));
|
|
terminal.write('\b'.repeat(terminalInfo.commandBuffer.length + 1));
|
|
|
|
// 写入新命令
|
|
terminal.write(terminalInfo.commandBuffer);
|
|
|
|
// 将光标移动到新位置
|
|
terminal.write('\b'.repeat(terminalInfo.commandBuffer.length - terminalInfo.cursorPosition));
|
|
}
|
|
}
|
|
}
|
|
// CTRL+C 处理
|
|
else if (input === '\x03') {
|
|
terminal.write('^C');
|
|
socket.send(JSON.stringify({
|
|
type: 'input',
|
|
content: '\x03'
|
|
}));
|
|
terminalInfo.commandBuffer = '';
|
|
terminalInfo.cursorPosition = 0; // 重置光标位置
|
|
}
|
|
// CTRL+D 处理
|
|
else if (input === '\x04') {
|
|
socket.send(JSON.stringify({
|
|
type: 'input',
|
|
content: '\x04'
|
|
}));
|
|
}
|
|
// 普通字符处理
|
|
else {
|
|
// 重置Tab补全状态
|
|
terminalInfo.tabMatches = null;
|
|
terminalInfo.tabIndex = undefined;
|
|
|
|
// 在光标位置插入字符
|
|
const before = terminalInfo.commandBuffer.substring(0, terminalInfo.cursorPosition);
|
|
const after = terminalInfo.commandBuffer.substring(terminalInfo.cursorPosition);
|
|
terminalInfo.commandBuffer = before + input + after;
|
|
terminalInfo.cursorPosition++;
|
|
|
|
// 如果光标在行尾,直接写入字符
|
|
if (terminalInfo.cursorPosition === terminalInfo.commandBuffer.length) {
|
|
terminal.write(input); // 回显输入的字符
|
|
} else {
|
|
// 否则,需要重新绘制光标后的所有字符
|
|
terminal.write(input + after);
|
|
|
|
// 将光标移回到正确位置
|
|
terminal.write('\b'.repeat(after.length));
|
|
}
|
|
}
|
|
};
|
|
terminal.onData(terminalInfo.inputHandler);
|
|
} else {
|
|
terminal.writeln(`\r\n连接状态: ${data.message || '未知'}\r\n`);
|
|
}
|
|
} else if (data.type === 'error') {
|
|
terminal.writeln(`\r\n错误: ${data.message}\r\n`);
|
|
|
|
// 如果是连接错误,可以考虑激活输入,避免终端卡死
|
|
if (data.message.includes('连接断开') || data.message.includes('ScrapliConnectionNotOpened')) {
|
|
terminal.writeln('检测到连接问题,服务器正在尝试重新连接...\r\n');
|
|
terminalInfo.inputActive = false;
|
|
}
|
|
|
|
// 如果会话管理器存在,更新状态为错误
|
|
if (window.sessionManager) {
|
|
window.sessionManager.updateSessionStatus(terminalInfo.id, 'error');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// 如果不是JSON格式,确保直接输出也有正确的换行
|
|
let output = event.data;
|
|
if (typeof output === 'string') {
|
|
output = output.replace(/\r\n|\n\r|\n|\r/g, '\r\n');
|
|
}
|
|
terminal.write(output);
|
|
}
|
|
};
|
|
|
|
socket.onclose = (event) => {
|
|
console.log(`WebSocket连接关闭,代码: ${event.code},原因: ${event.reason || '未提供'}`);
|
|
terminal.writeln('\r\n连接已关闭\r\n');
|
|
terminalInfo.inputActive = false;
|
|
|
|
// 如果会话管理器存在,更新状态为断开
|
|
if (window.sessionManager) {
|
|
window.sessionManager.updateSessionStatus(terminalInfo.id, 'disconnected');
|
|
}
|
|
|
|
// 设置标志,表示需要重新连接
|
|
terminalInfo.needReconnect = true;
|
|
|
|
// 如果不是用户主动关闭,尝试自动重连
|
|
if (!terminalInfo.manualClosed) {
|
|
terminal.writeln('将在5秒后尝试重新连接...\r\n');
|
|
|
|
// 清除之前的重连定时器
|
|
if (terminalInfo.reconnectTimer) {
|
|
clearTimeout(terminalInfo.reconnectTimer);
|
|
}
|
|
|
|
// 设置延迟重连
|
|
terminalInfo.reconnectTimer = setTimeout(() => {
|
|
if (terminalInfo.needReconnect) {
|
|
terminal.writeln('正在尝试重新连接...\r\n');
|
|
// 重新创建WebSocket连接
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${window.location.host}/ws/terminal/${deviceId}/${credentialId}`;
|
|
terminalInfo.socket = new WebSocket(wsUrl);
|
|
|
|
// 重新绑定事件处理器
|
|
this.setupSocketHandlers(terminalInfo, deviceId, credentialId);
|
|
}
|
|
}, 5000);
|
|
}
|
|
};
|
|
|
|
socket.onerror = (error) => {
|
|
terminal.writeln(`\r\n连接错误: ${error.message || '未知错误'}\r\n`);
|
|
console.error('WebSocket错误:', error);
|
|
terminalInfo.inputActive = false;
|
|
|
|
// 如果会话管理器存在,更新状态为错误
|
|
if (window.sessionManager) {
|
|
window.sessionManager.updateSessionStatus(terminalInfo.id, 'error');
|
|
}
|
|
};
|
|
}
|
|
|
|
// 手动重新连接当前活动终端
|
|
reconnectActiveTerminal() {
|
|
if (!this.activeTerminalId) {
|
|
console.log('没有活动的终端会话');
|
|
return;
|
|
}
|
|
|
|
const terminalInfo = this.getTerminalById(this.activeTerminalId);
|
|
if (!terminalInfo) {
|
|
console.log('找不到活动终端信息');
|
|
return;
|
|
}
|
|
|
|
// 检查终端是否已连接到设备
|
|
if (!terminalInfo.deviceId || !terminalInfo.credentialId) {
|
|
console.log('此终端未连接到任何设备');
|
|
terminalInfo.terminal.writeln('\r\n请先使用"连接设备"按钮连接到网络设备\r\n');
|
|
return;
|
|
}
|
|
|
|
// 关闭现有WebSocket连接
|
|
if (terminalInfo.socket) {
|
|
// 标记为手动重连,不是手动关闭
|
|
terminalInfo.manualClosed = false;
|
|
terminalInfo.needReconnect = true;
|
|
|
|
// 关闭现有连接
|
|
if (terminalInfo.socket.readyState === WebSocket.OPEN ||
|
|
terminalInfo.socket.readyState === WebSocket.CONNECTING) {
|
|
terminalInfo.socket.close();
|
|
}
|
|
}
|
|
|
|
// 清除所有计时器
|
|
if (terminalInfo.reconnectTimer) {
|
|
clearTimeout(terminalInfo.reconnectTimer);
|
|
terminalInfo.reconnectTimer = null;
|
|
}
|
|
|
|
if (terminalInfo.inputTimeoutId) {
|
|
clearTimeout(terminalInfo.inputTimeoutId);
|
|
terminalInfo.inputTimeoutId = null;
|
|
}
|
|
|
|
// 重置终端状态
|
|
terminalInfo.commandBuffer = '';
|
|
terminalInfo.cursorPosition = 0;
|
|
terminalInfo.inputActive = false; // 初始状态设为不接收输入,连接成功后会自动激活
|
|
|
|
// 向用户显示正在重新连接的消息
|
|
terminalInfo.terminal.writeln('\r\n正在手动重新连接设备...\r\n');
|
|
|
|
// 重新连接到设备
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${window.location.host}/ws/terminal/${terminalInfo.deviceId}/${terminalInfo.credentialId}`;
|
|
|
|
console.log(`正在重新连接WebSocket: ${wsUrl}`);
|
|
|
|
// 创建新的WebSocket连接
|
|
const newSocket = new WebSocket(wsUrl);
|
|
terminalInfo.socket = newSocket;
|
|
|
|
// 备份当前对象的引用
|
|
const self = this;
|
|
const terminal = terminalInfo.terminal;
|
|
|
|
// 为新连接设置事件处理器
|
|
newSocket.onopen = function() {
|
|
console.log('重新连接成功,等待设备响应');
|
|
terminal.writeln('\r\n重新连接成功,等待设备响应...\r\n');
|
|
terminalInfo.needReconnect = false;
|
|
|
|
// 初始化状态
|
|
terminalInfo.inputActive = false;
|
|
terminalInfo.commandBuffer = '';
|
|
terminalInfo.cursorPosition = 0;
|
|
|
|
// 给设备响应一些时间,然后手动激活输入
|
|
setTimeout(() => {
|
|
terminalInfo.inputActive = true;
|
|
console.log('手动激活输入状态');
|
|
terminal.writeln('\r\n输入已激活,可以开始输入命令\r\n');
|
|
}, 2000);
|
|
};
|
|
|
|
newSocket.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
console.log('收到消息类型:', data.type);
|
|
|
|
if (data.type === 'output') {
|
|
// 处理和格式化设备返回的内容
|
|
let content = data.content || '';
|
|
content = content.replace(/\r\n|\n\r|\n|\r/g, '\r\n');
|
|
terminal.write(content);
|
|
|
|
// 激活输入
|
|
terminalInfo.inputActive = true;
|
|
terminalInfo.cursorPosition = terminalInfo.commandBuffer.length;
|
|
|
|
} else if (data.type === 'status') {
|
|
if (data.connected) {
|
|
terminal.writeln(`\r\n已连接到设备: ${data.device_name}\r\n`);
|
|
terminalInfo.inputActive = true;
|
|
|
|
// 如果没有输入处理函数,则设置一个
|
|
if (!terminalInfo.inputHandler) {
|
|
self.setupInputHandler(terminalInfo);
|
|
}
|
|
} else {
|
|
terminal.writeln(`\r\n连接状态: ${data.message || '未知'}\r\n`);
|
|
}
|
|
} else if (data.type === 'error') {
|
|
terminal.writeln(`\r\n错误: ${data.message}\r\n`);
|
|
}
|
|
} catch (e) {
|
|
// 如果不是JSON格式,直接输出
|
|
let output = event.data;
|
|
if (typeof output === 'string') {
|
|
output = output.replace(/\r\n|\n\r|\n|\r/g, '\r\n');
|
|
}
|
|
terminal.write(output);
|
|
}
|
|
};
|
|
|
|
newSocket.onclose = (event) => {
|
|
console.log(`WebSocket连接关闭,代码: ${event.code}`);
|
|
terminal.writeln('\r\n连接已关闭\r\n');
|
|
terminalInfo.inputActive = false;
|
|
|
|
// 如果会话管理器存在,更新状态为断开
|
|
if (window.sessionManager) {
|
|
window.sessionManager.updateSessionStatus(terminalInfo.id, 'disconnected');
|
|
}
|
|
};
|
|
|
|
newSocket.onerror = (error) => {
|
|
terminal.writeln(`\r\n连接错误\r\n`);
|
|
console.error('WebSocket错误:', error);
|
|
terminalInfo.inputActive = false;
|
|
};
|
|
|
|
// 确保有输入处理函数
|
|
this.setupInputHandler(terminalInfo);
|
|
|
|
// 如果会话管理器存在,更新状态为连接中
|
|
if (window.sessionManager) {
|
|
window.sessionManager.updateSessionStatus(terminalInfo.id, 'connecting');
|
|
}
|
|
}
|
|
|
|
// 设置输入处理函数
|
|
setupInputHandler(terminalInfo) {
|
|
if (!terminalInfo || !terminalInfo.terminal) return;
|
|
|
|
const terminal = terminalInfo.terminal;
|
|
const socket = terminalInfo.socket;
|
|
|
|
// 先移除可能存在的旧处理函数
|
|
terminal.onData(() => {});
|
|
|
|
// 设置新的输入处理函数
|
|
terminalInfo.inputHandler = (input) => {
|
|
if (!terminalInfo.inputActive || (socket && socket.readyState !== WebSocket.OPEN)) {
|
|
return;
|
|
}
|
|
|
|
// 处理上下箭头键
|
|
if (input === '\x1b[A') { // 上箭头
|
|
this.navigateHistory(terminalInfo, 'up');
|
|
return;
|
|
} else if (input === '\x1b[B') { // 下箭头
|
|
this.navigateHistory(terminalInfo, 'down');
|
|
return;
|
|
} else if (input === '\x1b[C') { // 右箭头
|
|
this.moveCursor(terminalInfo, 'right');
|
|
return;
|
|
} else if (input === '\x1b[D') { // 左箭头
|
|
this.moveCursor(terminalInfo, 'left');
|
|
return;
|
|
} else if (input === '\t') { // Tab键 - 处理自动补全
|
|
this.handleTabCompletion(terminalInfo);
|
|
return;
|
|
}
|
|
|
|
// 回车键处理
|
|
if (input === '\r') {
|
|
// 重置Tab补全状态
|
|
terminalInfo.tabMatches = null;
|
|
terminalInfo.tabIndex = undefined;
|
|
|
|
const command = terminalInfo.commandBuffer;
|
|
|
|
// 添加到历史记录
|
|
if (command && (terminalInfo.commandHistory.length === 0 ||
|
|
terminalInfo.commandHistory[terminalInfo.commandHistory.length - 1] !== command)) {
|
|
terminalInfo.commandHistory.push(command);
|
|
if (terminalInfo.commandHistory.length > 100) {
|
|
terminalInfo.commandHistory.shift();
|
|
}
|
|
}
|
|
|
|
// 重置状态
|
|
terminalInfo.historyIndex = -1;
|
|
terminalInfo.currentCommand = '';
|
|
terminalInfo.cursorPosition = 0;
|
|
|
|
// 发送命令
|
|
socket.send(JSON.stringify({
|
|
type: 'input',
|
|
content: command + '\n'
|
|
}));
|
|
|
|
// 写入换行并禁用输入直到收到响应
|
|
terminal.write('\r\n');
|
|
terminalInfo.commandBuffer = '';
|
|
terminalInfo.inputActive = false;
|
|
|
|
// 防止超时
|
|
if (terminalInfo.inputTimeoutId) {
|
|
clearTimeout(terminalInfo.inputTimeoutId);
|
|
}
|
|
|
|
terminalInfo.inputTimeoutId = setTimeout(() => {
|
|
if (!terminalInfo.inputActive && socket.readyState === WebSocket.OPEN) {
|
|
terminalInfo.inputActive = true;
|
|
}
|
|
}, 10000);
|
|
|
|
return;
|
|
}
|
|
|
|
// 退格键处理
|
|
if (input === '\x7F') {
|
|
if (terminalInfo.commandBuffer.length > 0 && terminalInfo.cursorPosition > 0) {
|
|
// 重置Tab补全状态
|
|
terminalInfo.tabMatches = null;
|
|
terminalInfo.tabIndex = undefined;
|
|
|
|
// 在光标位置删除字符
|
|
const before = terminalInfo.commandBuffer.substring(0, terminalInfo.cursorPosition - 1);
|
|
const after = terminalInfo.commandBuffer.substring(terminalInfo.cursorPosition);
|
|
terminalInfo.commandBuffer = before + after;
|
|
terminalInfo.cursorPosition--;
|
|
|
|
// 重新绘制整行
|
|
terminal.write('\b \b'); // 删除光标前的字符
|
|
|
|
// 如果光标不在行尾,重绘命令
|
|
if (terminalInfo.cursorPosition < terminalInfo.commandBuffer.length) {
|
|
terminal.write('\b'.repeat(terminalInfo.cursorPosition));
|
|
terminal.write(' '.repeat(terminalInfo.commandBuffer.length + 1));
|
|
terminal.write('\b'.repeat(terminalInfo.commandBuffer.length + 1));
|
|
terminal.write(terminalInfo.commandBuffer);
|
|
terminal.write('\b'.repeat(terminalInfo.commandBuffer.length - terminalInfo.cursorPosition));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// CTRL+C 处理
|
|
if (input === '\x03') {
|
|
// 重置Tab补全状态
|
|
terminalInfo.tabMatches = null;
|
|
terminalInfo.tabIndex = undefined;
|
|
|
|
terminal.write('^C');
|
|
socket.send(JSON.stringify({
|
|
type: 'input',
|
|
content: '\x03'
|
|
}));
|
|
terminalInfo.commandBuffer = '';
|
|
terminalInfo.cursorPosition = 0;
|
|
return;
|
|
}
|
|
|
|
// CTRL+D 处理
|
|
if (input === '\x04') {
|
|
// 重置Tab补全状态
|
|
terminalInfo.tabMatches = null;
|
|
terminalInfo.tabIndex = undefined;
|
|
|
|
socket.send(JSON.stringify({
|
|
type: 'input',
|
|
content: '\x04'
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// 普通字符处理
|
|
// 重置Tab补全状态
|
|
terminalInfo.tabMatches = null;
|
|
terminalInfo.tabIndex = undefined;
|
|
|
|
const before = terminalInfo.commandBuffer.substring(0, terminalInfo.cursorPosition);
|
|
const after = terminalInfo.commandBuffer.substring(terminalInfo.cursorPosition);
|
|
terminalInfo.commandBuffer = before + input + after;
|
|
terminalInfo.cursorPosition++;
|
|
|
|
// 如果光标在行尾,直接写入
|
|
if (terminalInfo.cursorPosition === terminalInfo.commandBuffer.length) {
|
|
terminal.write(input);
|
|
} else {
|
|
// 否则重绘光标后的内容
|
|
terminal.write(input + after);
|
|
terminal.write('\b'.repeat(after.length));
|
|
}
|
|
};
|
|
|
|
// 绑定处理函数
|
|
terminal.onData(terminalInfo.inputHandler);
|
|
console.log('已设置终端输入处理函数,包含命令自动补全功能');
|
|
}
|
|
|
|
// 自动补全相关方法
|
|
|
|
// 根据当前输入查找匹配的命令
|
|
findMatchingCommands(terminalInfo, input) {
|
|
const vendorType = terminalInfo.vendorType || 'common';
|
|
let commandList = [];
|
|
|
|
// 基于设备类型选择命令列表
|
|
if (vendorType === 'cisco') {
|
|
commandList = [...this.commonCommands.cisco, ...this.commonCommands.common];
|
|
} else if (vendorType === 'huawei') {
|
|
commandList = [...this.commonCommands.huawei, ...this.commonCommands.common];
|
|
} else {
|
|
commandList = this.commonCommands.common;
|
|
}
|
|
|
|
// 查找匹配的命令
|
|
if (input) {
|
|
return commandList.filter(cmd => cmd.startsWith(input));
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// 处理Tab键自动补全
|
|
handleTabCompletion(terminalInfo) {
|
|
const terminal = terminalInfo.terminal;
|
|
const input = terminalInfo.commandBuffer;
|
|
|
|
// 如果已经有匹配列表并再次按Tab,则循环选择下一个
|
|
if (terminalInfo.tabMatches && terminalInfo.tabMatches.length > 1 && terminalInfo.tabIndex !== undefined) {
|
|
// 移到下一个匹配项,如果到末尾则循环到开头
|
|
terminalInfo.tabIndex = (terminalInfo.tabIndex + 1) % terminalInfo.tabMatches.length;
|
|
|
|
// 清除当前输入并显示新选择的命令
|
|
const newCommand = terminalInfo.tabMatches[terminalInfo.tabIndex];
|
|
|
|
// 计算需要退格的数量 - 当前缓冲区长度
|
|
const backspaces = terminalInfo.commandBuffer.length;
|
|
|
|
if (backspaces > 0) {
|
|
// 清除当前行
|
|
terminal.write('\b'.repeat(backspaces) + ' '.repeat(backspaces) + '\b'.repeat(backspaces));
|
|
}
|
|
|
|
// 写入新命令
|
|
terminal.write(newCommand);
|
|
terminalInfo.commandBuffer = newCommand;
|
|
terminalInfo.cursorPosition = newCommand.length;
|
|
|
|
return;
|
|
}
|
|
|
|
// 第一次按Tab键或没有匹配列表时执行初始匹配
|
|
const matches = this.findMatchingCommands(terminalInfo, input);
|
|
|
|
if (matches.length === 0) {
|
|
// 没有匹配项,不做任何操作
|
|
return;
|
|
} else if (matches.length === 1) {
|
|
// 只有一个匹配项,直接补全
|
|
const completion = matches[0].substring(input.length);
|
|
if (completion) {
|
|
// 更新命令缓冲区和光标位置
|
|
terminalInfo.commandBuffer = matches[0];
|
|
terminalInfo.cursorPosition = matches[0].length;
|
|
|
|
// 更新显示
|
|
terminal.write(completion);
|
|
}
|
|
|
|
// 清除任何现有的tab匹配状态
|
|
terminalInfo.tabMatches = null;
|
|
terminalInfo.tabIndex = undefined;
|
|
} else {
|
|
// 多个匹配项,显示所有可能性
|
|
terminal.writeln('');
|
|
|
|
// 计算最长命令的长度,用于格式化输出
|
|
const maxLength = Math.max(...matches.map(cmd => cmd.length)) + 2;
|
|
const termWidth = terminal.cols;
|
|
const itemsPerRow = Math.floor(termWidth / maxLength) || 1;
|
|
|
|
// 分行显示匹配项
|
|
let line = '';
|
|
for (let i = 0; i < matches.length; i++) {
|
|
const paddedCommand = matches[i].padEnd(maxLength);
|
|
line += paddedCommand;
|
|
|
|
if ((i + 1) % itemsPerRow === 0 || i === matches.length - 1) {
|
|
terminal.writeln(line);
|
|
line = '';
|
|
}
|
|
}
|
|
|
|
// 保存匹配列表,并设置当前索引为0(选择第一个匹配项)
|
|
terminalInfo.tabMatches = matches;
|
|
terminalInfo.tabIndex = 0;
|
|
|
|
// 清除旧命令并显示第一个匹配项
|
|
const firstMatch = matches[0];
|
|
|
|
// 重新显示提示符 - 使用设备提供的提示符或默认提示符
|
|
const promptText = terminalInfo.promptText || '[MCP]>';
|
|
terminal.write(promptText);
|
|
|
|
// 写入第一个匹配项作为新命令
|
|
terminal.write(firstMatch);
|
|
terminalInfo.commandBuffer = firstMatch;
|
|
terminalInfo.cursorPosition = firstMatch.length;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 原本在这里自动创建实例的代码已移除,改为在页面加载时统一创建
|