feat: SSHClient v1.0.0 - PyQt5 + paramiko 跨平台 SSH 客户端
功能: - 多主机管理 (增删改查, 密码/私钥双认证, 导入导出) - 远程终端 (命令执行 + 常用命令快捷栏 + 超时控制) - SFTP 文件浏览 (上传/下载带进度, 新建/删除/重命名) - 实时监控 (CPU/内存/磁盘/网络, 1-10秒可调刷新) - AI Agent (OpenAI 兼容 API, 5 工具自动调用: 命令/指标/列文件/读文件/上传) 技术栈: PyQt5 + paramiko + psutil + requests + PyInstaller 打包: build_windows.bat / build.sh 一键产出 ~57MB 单文件 exe 测试: core 6/6 + UI 3/3 + E2E 6/6 全部通过
This commit is contained in:
+218
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
后台工作线程:把阻塞操作(连接、执行命令、上传、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:
|
||||
code, out, err = self.conn.exec_command(self.command, timeout=self.timeout)
|
||||
except Exception as e:
|
||||
code, out, err = -1, "", str(e)
|
||||
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()}")
|
||||
Reference in New Issue
Block a user