Files
sshclient/ui/main_window.py
T
Hermes 8b5dacda58 fix: terminal command output not displaying - add diagnostics
User reported: '已连接, 但终端啥也不显示'
Likely cause: command ran but stdout was empty / scrolled to wrong place
/ output widget didn't refresh before worker finished.

Changes:
- Eagerly show the typed command in the output area (\$ <cmd>) so user
  sees the input was received even if the worker hangs/fails
- Show '(stdout 为空)' placeholder when worker returns no output, so it's
  clear the worker actually ran
- Force output.repaint() and ensureCursorVisible() after append
- Add 2 diagnostic buttons in terminal bottom bar:
    * [🔍 诊断]  prints current SSH connection / worker state
    * [▶ 测试命令]  pre-fills a known-good test command
- Make CommandWorker explicitly check conn.connected before exec
- Surface full traceback in stderr on exception instead of bare str(e)

All 15 existing tests still pass (core 6/6 + UI 3/3 + E2E 6/6).
2026-07-28 21:18:21 +08:00

497 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
主窗口
- 左侧:主机列表 + 操作
- 右侧:标签页(终端、文件浏览、监控、AI Agent)
"""
import sys
import time
from typing import Optional
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QFont, QIcon, QKeySequence
from PyQt5.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
QPushButton, QLineEdit, QLabel, QSplitter, QTabWidget, QPlainTextEdit,
QGroupBox, QFormLayout, QSpinBox, QMessageBox, QStatusBar, QAction,
QFileDialog, QInputDialog, QToolBar, QApplication, QStyle, QShortcut,
)
from core.manager import ConnectionManager
from core.ai_agent import AIAgent
from .workers import ConnectWorker, CommandWorker
from .widgets import FileBrowser, MonitorPanel, AIChatPanel
from .config_dialog import AIConfigDialog
APP_NAME = "SSHClient"
APP_VERSION = "1.0.0"
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION} - AI 增强 SSH 客户端")
self.resize(1280, 800)
self.manager = ConnectionManager()
self.agent = AIAgent()
self.current_host_id: Optional[str] = None
self.cmd_worker: Optional[CommandWorker] = None
self.connect_worker: Optional[ConnectWorker] = None
self._build_ui()
self._build_menu()
self._build_statusbar()
self._load_hosts_to_list()
# ============================================================
# UI 构建
# ============================================================
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
root = QHBoxLayout(central)
root.setContentsMargins(6, 6, 6, 6)
root.setSpacing(6)
splitter = QSplitter(Qt.Horizontal)
root.addWidget(splitter)
# ====== 左侧:主机面板 ======
left = QWidget()
lv = QVBoxLayout(left)
lv.setContentsMargins(4, 4, 4, 4)
lv.setSpacing(6)
host_title = QLabel("🖥 主机")
host_title.setStyleSheet("font-size: 12pt; font-weight: bold; padding: 4px;")
lv.addWidget(host_title)
self.host_list = QListWidget()
self.host_list.itemSelectionChanged.connect(self._on_host_selected)
self.host_list.itemDoubleClicked.connect(lambda _: self._do_connect())
lv.addWidget(self.host_list, 1)
# 主机操作按钮
btn_grid = QVBoxLayout()
btn_grid.setSpacing(4)
self.btn_add = QPushButton(" 新增主机")
self.btn_add.clicked.connect(self._add_host)
self.btn_edit = QPushButton("✏ 编辑")
self.btn_edit.clicked.connect(self._edit_host)
self.btn_delete = QPushButton("🗑 删除")
self.btn_delete.clicked.connect(self._delete_host)
self.btn_connect = QPushButton("🔌 连接")
self.btn_connect.clicked.connect(self._do_connect)
self.btn_disconnect = QPushButton("⛔ 断开")
self.btn_disconnect.clicked.connect(self._do_disconnect)
for b in (self.btn_add, self.btn_edit, self.btn_delete,
self.btn_connect, self.btn_disconnect):
btn_grid.addWidget(b)
lv.addLayout(btn_grid)
# 连接状态
self.conn_status_label = QLabel("未选择")
self.conn_status_label.setStyleSheet("color: #666; padding: 4px;")
lv.addWidget(self.conn_status_label)
splitter.addWidget(left)
# ====== 右侧:Tab 区 ======
self.tabs = QTabWidget()
self.tabs.setDocumentMode(True)
# Tab1: 终端
self.terminal = self._build_terminal_tab()
self.tabs.addTab(self.terminal, "⌨ 终端")
# Tab2: 文件浏览
self.file_browser = FileBrowser(self.manager)
self.tabs.addTab(self.file_browser, "📁 文件")
# Tab3: 监控
self.monitor = MonitorPanel(self.manager)
self.tabs.addTab(self.monitor, "📊 监控")
# Tab4: AI Agent
self.ai_panel = AIChatPanel(self.manager, self.agent)
self.tabs.addTab(self.ai_panel, "🤖 AI Agent")
splitter.addWidget(self.tabs)
splitter.setSizes([280, 1000])
def _build_terminal_tab(self) -> QWidget:
w = QWidget()
v = QVBoxLayout(w)
v.setContentsMargins(6, 6, 6, 6)
v.setSpacing(4)
# 命令输入
cmd_row = QHBoxLayout()
self.cmd_input = QLineEdit()
self.cmd_input.setPlaceholderText("输入命令,回车执行 (例如: ls -la /tmp)")
self.cmd_input.returnPressed.connect(self._run_command)
QShortcut(QKeySequence("Ctrl+Return"), self.cmd_input,
activated=self._run_command)
self.btn_run = QPushButton("执行")
self.btn_run.clicked.connect(self._run_command)
self.timeout_spin = QSpinBox()
self.timeout_spin.setRange(5, 600)
self.timeout_spin.setValue(30)
self.timeout_spin.setSuffix("")
cmd_row.addWidget(QLabel("$"))
cmd_row.addWidget(self.cmd_input, 1)
cmd_row.addWidget(QLabel("超时:"))
cmd_row.addWidget(self.timeout_spin)
cmd_row.addWidget(self.btn_run)
v.addLayout(cmd_row)
# 快速命令栏
quick_row = QHBoxLayout()
quick_row.addWidget(QLabel("常用:"))
for label, cmd in [
("pwd && uname -a", "pwd && uname -a"),
("df -h", "df -h"),
("free -h", "free -h"),
("top -bn1 | head -20", "top -bn1 | head -20"),
("netstat -tlnp", "netstat -tlnp 2>/dev/null || ss -tlnp"),
("ls /etc", "ls -la /etc"),
]:
b = QPushButton(label)
b.clicked.connect(lambda _, c=cmd: self.cmd_input.setText(c))
quick_row.addWidget(b)
quick_row.addStretch(1)
v.addLayout(quick_row)
# 输出
self.output = QPlainTextEdit()
self.output.setReadOnly(True)
self.output.setStyleSheet("""
QPlainTextEdit {
background: #0c0c0c;
color: #e0e0e0;
font-family: Consolas, 'Courier New', monospace;
font-size: 10pt;
}
""")
v.addWidget(self.output, 1)
# 输出操作
bottom = QHBoxLayout()
self.btn_clear_out = QPushButton("清空")
self.btn_clear_out.clicked.connect(self.output.clear)
self.btn_copy = QPushButton("复制输出")
self.btn_copy.clicked.connect(lambda: QApplication.clipboard().setText(self.output.toPlainText()))
self.btn_debug = QPushButton("🔍 诊断")
self.btn_debug.setToolTip("打印 SSH 连接和 worker 状态")
self.btn_debug.clicked.connect(self._debug_state)
self.btn_test = QPushButton("▶ 测试命令")
self.btn_test.setToolTip("执行 'echo hello-sshclient && pwd && uname -a' 验证连通性")
self.btn_test.clicked.connect(lambda: self.cmd_input.setText("echo hello-sshclient && pwd && uname -a"))
bottom.addWidget(self.btn_clear_out)
bottom.addWidget(self.btn_copy)
bottom.addWidget(self.btn_debug)
bottom.addStretch(1)
bottom.addWidget(self.btn_test)
v.addLayout(bottom)
return w
def _build_menu(self):
menubar = self.menuBar()
# 文件
m_file = menubar.addMenu("文件(&F)")
act_export = QAction("导出主机配置", self)
act_export.triggered.connect(self._export_hosts)
m_file.addAction(act_export)
act_import = QAction("导入主机配置", self)
act_import.triggered.connect(self._import_hosts)
m_file.addAction(act_import)
m_file.addSeparator()
act_exit = QAction("退出", self)
act_exit.setShortcut("Ctrl+Q")
act_exit.triggered.connect(self.close)
m_file.addAction(act_exit)
# AI
m_ai = menubar.addMenu("AI(&A)")
act_ai = QAction("⚙ AI 设置...", self)
act_ai.triggered.connect(self._show_ai_config)
m_ai.addAction(act_ai)
act_clear = QAction("清空 AI 对话", self)
act_clear.triggered.connect(lambda: self.ai_panel._clear())
m_ai.addAction(act_clear)
# 帮助
m_help = menubar.addMenu("帮助(&H)")
act_about = QAction("关于", self)
act_about.triggered.connect(self._about)
m_help.addAction(act_about)
def _build_statusbar(self):
self.statusBar().showMessage(f"{APP_NAME} v{APP_VERSION} 就绪")
# ============================================================
# 主机列表
# ============================================================
def _load_hosts_to_list(self):
self.host_list.clear()
for h in self.manager.list_hosts():
item = QListWidgetItem(f"{h.get('name', h.get('host'))}\n {h.get('username','')}@{h.get('host','')}:{h.get('port',22)}")
item.setData(Qt.UserRole, h.get("id"))
self.host_list.addItem(item)
# 默认选中第一项
if self.host_list.count() > 0:
self.host_list.setCurrentRow(0)
self._refresh_status_indicator()
def _on_host_selected(self):
items = self.host_list.selectedItems()
if not items:
self.current_host_id = None
self.terminal_input_set_enabled(False)
return
host_id = items[0].data(Qt.UserRole)
self.current_host_id = host_id
self.file_browser.set_host(host_id)
self.monitor.set_host(host_id)
self.ai_panel.set_host(host_id)
self._refresh_status_indicator()
def _refresh_status_indicator(self):
if not self.current_host_id:
self.conn_status_label.setText("未选择主机")
return
c = self.manager.get_connection(self.current_host_id)
if c and c.connected:
self.conn_status_label.setText(f"🟢 已连接: {c.username}@{c.host}:{c.port}")
self.conn_status_label.setStyleSheet("color: #2e7d32; padding: 4px;")
self.terminal_input_set_enabled(True)
else:
self.conn_status_label.setText("🔴 未连接")
self.conn_status_label.setStyleSheet("color: #c62828; padding: 4px;")
self.terminal_input_set_enabled(False)
def terminal_input_set_enabled(self, enabled: bool):
self.cmd_input.setEnabled(enabled)
self.btn_run.setEnabled(enabled)
# ============================================================
# 主机 CRUD
# ============================================================
def _add_host(self):
info = self._prompt_host_info()
if info is None:
return
self.manager.add_host(info)
self._load_hosts_to_list()
self.statusBar().showMessage("已新增主机", 3000)
def _edit_host(self):
if not self.current_host_id:
return
h = self.manager.get_host(self.current_host_id)
if not h:
return
info = self._prompt_host_info(h)
if info is None:
return
self.manager.update_host(self.current_host_id, info)
self._load_hosts_to_list()
self.statusBar().showMessage("已更新主机", 3000)
def _delete_host(self):
if not self.current_host_id:
return
h = self.manager.get_host(self.current_host_id)
if not h:
return
if QMessageBox.question(
self, "确认删除",
f"确定删除主机「{h.get('name', h.get('host'))}」?",
QMessageBox.Yes | QMessageBox.No
) != QMessageBox.Yes:
return
self.manager.remove_host(self.current_host_id)
self._load_hosts_to_list()
self.statusBar().showMessage("已删除", 3000)
def _prompt_host_info(self, current: Optional[dict] = None):
from .host_dialog import HostDialog
dlg = HostDialog(self, current)
if dlg.exec_() == dlg.Accepted:
return dlg.get_value()
return None
# ============================================================
# 连接
# ============================================================
def _do_connect(self):
if not self.current_host_id:
QMessageBox.information(self, "提示", "请先选择一台主机")
return
if self.connect_worker and self.connect_worker.isRunning():
return
h = self.manager.get_host(self.current_host_id)
self.statusBar().showMessage(f"正在连接 {h.get('host')}...")
self.btn_connect.setEnabled(False)
self.connect_worker = ConnectWorker(self.manager, self.current_host_id)
self.connect_worker.finished_with.connect(self._on_connect_done)
self.connect_worker.start()
def _on_connect_done(self, host_id: str, ok: bool, msg: str):
self.btn_connect.setEnabled(True)
if ok:
self.statusBar().showMessage(msg, 5000)
# 同步到 UI
self.file_browser.set_host(host_id)
self.monitor.set_host(host_id)
else:
QMessageBox.critical(self, "连接失败", msg)
self.statusBar().showMessage(f"连接失败: {msg}", 5000)
self._refresh_status_indicator()
def _do_disconnect(self):
if not self.current_host_id:
return
self.manager.disconnect(self.current_host_id)
self.statusBar().showMessage("已断开", 3000)
self._refresh_status_indicator()
# 监控如果开着也会自己检测到
# ============================================================
# 命令执行
# ============================================================
def _run_command(self):
cmd = self.cmd_input.text().strip()
if not cmd:
return
if not self.current_host_id:
QMessageBox.warning(self, "提示", "请先连接主机")
return
conn = self.manager.get_connection(self.current_host_id)
if not conn or not conn.connected:
QMessageBox.warning(self, "提示", "当前主机未连接")
return
# 先在输出区显示命令(即使后续 worker 失败也能看到用户输入了啥)
self._append_out(f"\n$ {cmd}")
self.cmd_input.clear()
self.btn_run.setEnabled(False)
self.btn_run.setText("执行中...")
timeout = self.timeout_spin.value()
self.cmd_worker = CommandWorker(conn, cmd, timeout)
self.cmd_worker.finished_with.connect(self._on_cmd_done)
self.cmd_worker.start()
# 强制刷新滚动到底
self.output.repaint()
def _on_cmd_done(self, code: int, out: str, err: str):
self.btn_run.setEnabled(True)
self.btn_run.setText("执行")
if out:
self._append_out(out)
else:
self._append_out("(stdout 为空)")
if err:
self._append_out_err(err)
self._append_out(f"[exit={code}]")
self.statusBar().showMessage(f"命令完成 (退出码 {code})", 3000)
# 强制刷新 + 滚到底
self.output.repaint()
def _append_out(self, text: str):
# 不再 rstrip("\n"),保留原样;appendPlainText 本身会处理换行
self.output.appendPlainText(text)
# 滚到底
sb = self.output.verticalScrollBar()
sb.setValue(sb.maximum())
self.output.ensureCursorVisible()
def _append_out_err(self, text: str):
self.output.appendPlainText("[ERR] " + text.replace("\n", "\n[ERR] "))
sb = self.output.verticalScrollBar()
sb.setValue(sb.maximum())
def _debug_state(self):
"""诊断按钮:打印当前 SSH 连接和 worker 状态"""
lines = ["=== Debug State ==="]
lines.append(f"current_host_id: {self.current_host_id}")
if self.current_host_id:
h = self.manager.get_host(self.current_host_id)
lines.append(f"host config: {h}")
conn = self.manager.get_connection(self.current_host_id)
if conn:
lines.append(f"conn.connected: {conn.connected}")
lines.append(f"conn.client: {conn.client}")
lines.append(f"conn.sftp: {conn.sftp}")
if conn.client:
try:
transport = conn.client.get_transport()
lines.append(f"transport active: {transport.is_active() if transport else 'None'}")
except Exception as e:
lines.append(f"transport error: {e}")
else:
lines.append("conn: None (not connected)")
lines.append(f"cmd_worker: {self.cmd_worker}")
if self.cmd_worker:
lines.append(f"cmd_worker.isRunning: {self.cmd_worker.isRunning()}")
lines.append("=== End Debug ===")
self._append_out("\n".join(lines))
self.statusBar().showMessage("诊断信息已输出", 3000)
# ============================================================
# AI / 关于
# ============================================================
def _show_ai_config(self):
dlg = AIConfigDialog(self.agent, self)
if dlg.exec_():
self.statusBar().showMessage("AI 配置已更新", 3000)
def _about(self):
QMessageBox.about(
self, "关于",
f"<h3>{APP_NAME} v{APP_VERSION}</h3>"
"<p>基于 PyQt5 + paramiko 的 Windows SSH 客户端</p>"
"<ul>"
"<li>多主机管理与连接</li>"
"<li>远程终端</li>"
"<li>SFTP 文件浏览 / 上传 / 下载</li>"
"<li>CPU / 内存 / 磁盘 / 网络 实时监控</li>"
"<li>AI AgentOpenAI 兼容 API,支持工具调用)</li>"
"</ul>"
"<p style='color:#888;'>本程序使用 paramiko、PyQt5、psutil、requests 等开源库。</p>"
)
def _export_hosts(self):
import json
path, _ = QFileDialog.getSaveFileName(self, "导出主机配置", "hosts.json", "JSON (*.json)")
if not path:
return
with open(path, "w", encoding="utf-8") as f:
json.dump(self.manager.list_hosts(), f, ensure_ascii=False, indent=2)
self.statusBar().showMessage(f"已导出到 {path}", 5000)
def _import_hosts(self):
import json
path, _ = QFileDialog.getOpenFileName(self, "导入主机配置", "", "JSON (*.json)")
if not path:
return
try:
with open(path, "r", encoding="utf-8") as f:
hosts = json.load(f)
for h in hosts:
if "id" in h:
del h["id"]
self.manager.add_host(h)
self._load_hosts_to_list()
self.statusBar().showMessage(f"已导入 {len(hosts)} 台主机", 5000)
except Exception as e:
QMessageBox.critical(self, "导入失败", str(e))
def closeEvent(self, e):
try:
self.monitor._stop_worker()
self.manager.close_all()
except Exception:
pass
super().closeEvent(e)