8b5dacda58
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).
223 lines
8.4 KiB
Python
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()}")
|