Files
Your Name 5d83348db3 fix: monitor worker non-blocking + test fixes
- core/monitor.py: add fast metrics script (0.3s vs 1.4s sleep),
  collect() now defaults to fast=True for responsive UI
- ui/workers.py: MonitorWorker changed from loop mode to single-shot
  (QTimer re-arms next cycle on finished signal, avoids paramiko
  blocking in infinite loop)
- ui/widgets.py: remove debug print from _kick_one_sample
- test_monitor_nonblock.py: fix sample counting by wrapping _on_sample
  instead of disconnecting signals (old approach missed new workers)
- test_process_monitor.py: assertions account for MAX_RENDER_ROWS=200,
  fix MEM% column index (4 not 3) in sort test
2026-07-28 22:57:15 +08:00

225 lines
8.5 KiB
Python
Raw Permalink 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 推理)从 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 _SystemMonitorWorker(QThread):
"""单次采集 + QTimer 周期触发(避免 run() 死循环 + paramiko 阻塞问题)"""
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):
"""单次采集一帧;由主线程用 QTimer 调度下一次"""
if self._stop:
return
try:
m = SystemMonitor.collect(self.conn)
self.sample_ready.emit(m)
except Exception as e:
self.error.emit(str(e))
# 保留旧类名做兼容(一些其他地方可能引用了 MonitorWorker
MonitorWorker = _SystemMonitorWorker
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()}")