Files
sshclient/ui/workers.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

223 lines
8.4 KiB
Python

"""
后台工作线程:把阻塞操作(连接、执行命令、上传、AI 推理)从 UI 线程中剥离。
所有线程通过信号与 UI 通信,UI 不阻塞。
"""
import time
import traceback
from typing import Optional
from PyQt5.QtCore import QThread, pyqtSignal
from core.ssh_client import SSHConnection
from core.monitor import SystemMonitor
from core.ai_agent import AIAgent
from core.manager import ConnectionManager
class ConnectWorker(QThread):
"""后台建立 SSH 连接"""
finished_with = pyqtSignal(str, bool, str) # host_id, ok, msg
def __init__(self, manager: ConnectionManager, host_id: str):
super().__init__()
self.manager = manager
self.host_id = host_id
def run(self):
conn, ok, msg = self.manager.connect(self.host_id)
self.finished_with.emit(self.host_id, ok, msg)
class CommandWorker(QThread):
"""后台执行远程命令"""
finished_with = pyqtSignal(int, str, str) # exit_code, stdout, stderr
def __init__(self, conn: SSHConnection, command: str, timeout: int = 30):
super().__init__()
self.conn = conn
self.command = command
self.timeout = timeout
def run(self):
try:
if not self.conn or not self.conn.connected or not self.conn.client:
self.finished_with.emit(-1, "", "连接已断开或 client 不可用")
return
code, out, err = self.conn.exec_command(self.command, timeout=self.timeout)
except Exception as e:
import traceback
code, out, err = -1, "", f"执行异常: {e}\n{traceback.format_exc()}"
self.finished_with.emit(code, out, err)
class MonitorWorker(QThread):
"""后台采集系统指标;循环模式"""
sample_ready = pyqtSignal(dict)
error = pyqtSignal(str)
def __init__(self, conn: SSHConnection, interval: int = 3):
super().__init__()
self.conn = conn
self.interval = interval
self._stop = False
def stop(self):
self._stop = True
def run(self):
while not self._stop:
try:
m = SystemMonitor.collect(self.conn)
self.sample_ready.emit(m)
except Exception as e:
self.error.emit(str(e))
for _ in range(self.interval * 10):
if self._stop:
return
time.sleep(0.1)
class UploadWorker(QThread):
"""后台 SFTP 上传(带进度)"""
progress = pyqtSignal(int, int) # done, total
finished_with = pyqtSignal(bool, str)
def __init__(self, conn: SSHConnection, local: str, remote: str):
super().__init__()
self.conn = conn
self.local = local
self.remote = remote
def run(self):
try:
ok, msg = self.conn.upload(self.local, self.remote,
progress_cb=lambda d, t: self.progress.emit(d, t))
except Exception as e:
ok, msg = False, f"异常: {e}"
self.finished_with.emit(ok, msg)
class DownloadWorker(QThread):
"""后台 SFTP 下载(带进度)"""
progress = pyqtSignal(int, int)
finished_with = pyqtSignal(bool, str)
def __init__(self, conn: SSHConnection, remote: str, local: str):
super().__init__()
self.conn = conn
self.remote = remote
self.local = local
def run(self):
try:
ok, msg = self.conn.download(self.remote, self.local,
progress_cb=lambda d, t: self.progress.emit(d, t))
except Exception as e:
ok, msg = False, f"异常: {e}"
self.finished_with.emit(ok, msg)
class ListDirWorker(QThread):
"""后台列目录"""
finished_with = pyqtSignal(str, list) # path, entries
def __init__(self, conn: SSHConnection, path: str):
super().__init__()
self.conn = conn
self.path = path
def run(self):
entries = self.conn.list_dir(self.path)
self.finished_with.emit(self.path, entries)
class AIWorker(QThread):
"""后台 AI 对话(避免 UI 卡顿)"""
step = pyqtSignal(str, str) # role, content
finished_with = pyqtSignal(str) # final reply
error = pyqtSignal(str)
def __init__(self, agent: AIAgent, user_msg: str, conn: Optional[SSHConnection]):
super().__init__()
self.agent = agent
self.user_msg = user_msg
self.conn = conn
def _tool(self, name: str, args: dict) -> str:
"""AI 工具调用 -> 真实执行"""
try:
if name == "exec_ssh_command":
if not self.conn or not self.conn.connected:
return "[错误] 当前没有可用的 SSH 连接"
cmd = args.get("command", "")
timeout = min(int(args.get("timeout", 30)), 300)
code, out, err = self.conn.exec_command(cmd, timeout=timeout)
out_s = out[:3000] + ("\n...(stdout 截断)" if len(out) > 3000 else "")
err_s = err[:1500] + ("\n...(stderr 截断)" if len(err) > 1500 else "")
return f"exit={code}\nstdout:\n{out_s}\nstderr:\n{err_s}"
if name == "get_system_metrics":
m = SystemMonitor.collect(self.conn) if self.conn else {}
# 简化展示给模型
summary = (
f"hostname={m.get('hostname','')} os={m.get('os','')} "
f"cpu={m.get('cpu',0):.1f}% cores={m.get('cores',1)} "
f"load1={m.get('load1',0):.2f} "
f"mem={m.get('mem_percent',0):.1f}% "
f"({SystemMonitor.format_bytes(m.get('mem_used',0))}/"
f"{SystemMonitor.format_bytes(m.get('mem_total',0))})"
)
disks = "; ".join(
f"{d['mount']}={d['percent']}%"
for d in m.get("disks", [])
)
nets = "; ".join(
f"{n['iface']}(rx={SystemMonitor.format_bytes(n['rx'])},"
f"tx={SystemMonitor.format_bytes(n['tx'])})"
for n in m.get("net", [])
)
return f"{summary}\ndisks: {disks}\nnet: {nets}"
if name == "list_remote_files":
if not self.conn or not self.conn.connected:
return "[错误] 当前没有可用的 SSH 连接"
path = args.get("path", "/")
entries = self.conn.list_dir(path)
if not entries:
return f"(空目录或无权限: {path})"
lines = []
for e in entries[:200]:
tag = "d" if e["is_dir"] else "-"
lines.append(f"{tag} {e['size']:>10} {e['name']}")
return f"path={path}\n" + "\n".join(lines)
if name == "read_remote_file":
if not self.conn or not self.conn.connected:
return "[错误] 当前没有可用的 SSH 连接"
path = args.get("path", "")
if not path:
return "[错误] 缺少 path 参数"
# 用 cat,避免 SFTP 打开大文件
code, out, err = self.conn.exec_command(f"cat '{path}' 2>&1 | head -c 8000")
return out if code == 0 else f"[错误 exit={code}] {err}"
if name == "upload_local_file":
if not self.conn or not self.conn.connected:
return "[错误] 当前没有可用的 SSH 连接"
local = args.get("local_path", "")
remote = args.get("remote_path", "")
if not local or not remote:
return "[错误] 缺少 local_path 或 remote_path"
import os
if not os.path.isfile(local):
return f"[错误] 本地文件不存在: {local}"
ok, msg = self.conn.upload(local, remote)
return ("成功: " if ok else "失败: ") + msg
return f"[未知工具] {name}"
except Exception as e:
return f"[工具异常 {name}] {e}\n{traceback.format_exc()}"
def run(self):
try:
final = self.agent.chat(self.user_msg, self._tool,
on_step=lambda r, c: self.step.emit(r, c))
self.finished_with.emit(final)
except Exception as e:
self.error.emit(f"{e}\n{traceback.format_exc()}")