5d83348db3
- 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
225 lines
8.5 KiB
Python
225 lines
8.5 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 _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()}")
|